diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a60b7a0306..cf0686db1a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,8 @@ # Global code owner * @mnriem +# Community catalog files — explicit ownership for when global ownership expands +/extensions/catalog.community.json @mnriem +/integrations/catalog.community.json @mnriem +/presets/catalog.community.json @mnriem + diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index 37b0fea5bf..1a44adec2d 100644 --- a/.github/ISSUE_TEMPLATE/agent_request.yml +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -8,7 +8,7 @@ body: 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 + **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, Devin for Terminal - type: input id: agent-name diff --git a/.github/ISSUE_TEMPLATE/preset_submission.yml b/.github/ISSUE_TEMPLATE/preset_submission.yml index 3a1b963492..f80e9cbdc5 100644 --- a/.github/ISSUE_TEMPLATE/preset_submission.yml +++ b/.github/ISSUE_TEMPLATE/preset_submission.yml @@ -95,11 +95,18 @@ body: validations: required: true + - type: input + id: required-extensions + attributes: + label: Required Extensions (optional) + description: Comma-separated list of required extension IDs (e.g., aide) + placeholder: "e.g., aide, canon" + - type: textarea id: templates-provided attributes: label: Templates Provided - description: List the template overrides your preset provides + description: List the template overrides your preset provides (enter "None" if command-only) placeholder: | - spec-template.md — adds compliance section - plan-template.md — includes audit checkpoints @@ -110,10 +117,19 @@ body: - type: textarea id: commands-provided attributes: - label: Commands Provided (optional) - description: List any command overrides your preset provides + label: Commands Provided + description: List the command overrides your preset provides (enter "None" if template-only) placeholder: | - speckit.specify.md — customized for compliance workflows + validations: + required: true + + - type: input + id: scripts-count + attributes: + label: Number of Scripts (optional) + description: How many scripts does your preset provide? (leave empty if none) + placeholder: "e.g., 1" - type: textarea id: tags diff --git a/.github/workflows/catalog-assign.yml b/.github/workflows/catalog-assign.yml new file mode 100644 index 0000000000..4191bcc554 --- /dev/null +++ b/.github/workflows/catalog-assign.yml @@ -0,0 +1,59 @@ +name: "Catalog: Auto-assign submission" + +on: + issues: + types: [opened, labeled] + +jobs: + assign: + if: > + (github.event.action == 'opened' && ( + contains(github.event.issue.labels.*.name, 'extension-submission') || + contains(github.event.issue.labels.*.name, 'preset-submission') + )) || + (github.event.action == 'labeled' && ( + github.event.label.name == 'extension-submission' || + github.event.label.name == 'preset-submission' + )) + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const assigned = (issue.assignees || []).map(a => a.login); + const marker = ''; + + // Assign mnriem if not already assigned + if (!assigned.includes('mnriem')) { + try { + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + assignees: ['mnriem'], + }); + } catch (e) { + console.log(`Warning: could not assign mnriem: ${e.message}`); + } + } + + // Post team notification if not already posted + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + } + ); + if (!comments.some(c => c.body && c.body.includes(marker))) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: marker + '\ncc @github/spec-kit-maintainers — new catalog submission for review.', + }); + } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 01e0df4a51..1af463c718 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,14 +19,14 @@ jobs: language: [ 'actions', 'python' ] steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 847f564557..9cb48f8f38 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,15 +26,16 @@ concurrency: jobs: # Build job build: + if: github.repository == 'github/spec-kit' runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 # Fetch all history for git info - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: '8.x' @@ -47,15 +48,16 @@ jobs: docfx docfx.json - name: Setup Pages - uses: actions/configure-pages@v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # 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,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 - + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fdece63093..3b2ad70bfb 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@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Run markdownlint-cli2 - uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 # v23 + uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23 with: globs: | '**/*.md' diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml index a451accfe6..c3728e2363 100644 --- a/.github/workflows/release-trigger.yml +++ b/.github/workflows/release-trigger.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 token: ${{ secrets.RELEASE_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b903cf979..9437bd02e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -86,4 +86,3 @@ jobs: --notes-file release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 076d05336a..919add00f0 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -14,7 +14,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v10 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10 with: # Days of inactivity before an issue or PR becomes stale days-before-stale: 150 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18b039f02b..f7130aa8d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,13 +13,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.13" @@ -27,24 +27,29 @@ jobs: run: uvx ruff check src/ pytest: - runs-on: ubuntu-latest + 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 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # 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 c7a06ea59b..d711b4214d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,277 +10,224 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their --- -## 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 - -Specify supports multiple AI agents by generating agent-specific command files and directory structures when initializing projects. Each agent has its own conventions for: - -- **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 - -| 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 | N/A (IDE-based) | Cursor IDE (`--ai cursor-agent`) | -| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI | -| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI | -| **Codex CLI** | `.agents/skills/` | Markdown | `codex` | Codex CLI (`--ai codex --ai-skills`) | -| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows | -| **Junie** | `.junie/commands/` | Markdown | `junie` | Junie by JetBrains | -| **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE | -| **Auggie CLI** | `.augment/commands/` | Markdown | `auggie` | Auggie CLI | -| **Roo Code** | `.roo/commands/` | Markdown | N/A (IDE-based) | Roo Code IDE | -| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI | -| **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI | -| **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI | -| **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI | -| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | -| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI | -| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) | -| **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent | -| **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) | -| **Forge** | `.forge/commands/` | Markdown | `forge` | Forge CLI (forgecode.dev) | -| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | -| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE | -| **Antigravity** | `.agent/commands/` | Markdown | N/A (IDE-based) | Antigravity IDE (`--ai agy --ai-skills`) | -| **Mistral Vibe** | `.vibe/prompts/` | Markdown | `vibe` | Mistral Vibe CLI | -| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent | - -### Step-by-Step Integration Guide - -Follow these steps to add a new agent (using a hypothetical new agent as an example): - -#### 1. Add to AGENT_CONFIG - -**IMPORTANT**: Use the actual CLI tool name as the key, not a shortened version. - -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: +## Integration Architecture -```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 - "commands_subdir": "commands", # Subdirectory name for command files (default: "commands") - "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 - }, -} -``` +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()`. -**Key Design Principle**: The dictionary key should match the actual executable name that users install. For example: +``` +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 +├── gemini/ # Example: TomlIntegration subclass +│ └── __init__.py +├── windsurf/ # Example: MarkdownIntegration subclass +│ └── __init__.py +├── copilot/ # Example: IntegrationBase subclass (custom setup) +│ └── __init__.py +└── ... # One subpackage per supported agent +``` -- ✅ 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` +The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, capabilities, and context files are derived from the integration classes for the Python integration layer. -This eliminates the need for special-case mappings throughout the codebase. +--- -**Field Explanations**: +## Adding a New Integration -- `name`: Human-readable display name shown to users -- `folder`: Directory where agent-specific files are stored (relative to project root) -- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`) - - Most agents use `"commands"` (e.g., `.claude/commands/`) - - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli, pi), `"command"` (opencode - singular) - - This field enables `--ai-skills` to locate command templates correctly for skill generation -- `install_url`: Installation documentation URL (set to `None` for IDE-based agents) -- `requires_cli`: Whether the agent requires a CLI tool check during initialization +### 1. Choose a base class -#### 2. Update CLI Help Text +| 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 | -Update the `--ai` parameter help text in the `init()` command to include the new agent: +Most agents only need `MarkdownIntegration` — a minimal subclass with zero method overrides. -```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 kiro-cli"), -``` +### 2. Create the subpackage -Also update any function docstrings, examples, and error messages that list available agents. +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. -#### 3. Update README Documentation +**Minimal example — Markdown agent (Windsurf):** -Update the **Supported AI Agents** section in `README.md` to include the new agent: - -- 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 - -#### 4. Update Release Package Script +```python +"""Windsurf IDE integration.""" -Modify `.github/workflows/scripts/create-release-packages.sh`: +from ..base import MarkdownIntegration -##### Add to ALL_AGENTS array -```bash -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf kiro-cli) +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" ``` -##### Add case statement for directory structure +**TOML agent (Gemini):** -```bash -case $agent in - # ... existing cases ... - windsurf) - mkdir -p "$base_dir/.windsurf/workflows" - generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;; -esac -``` +```python +"""Gemini CLI integration.""" -#### 4. Update GitHub Release Script +from ..base import TomlIntegration -Modify `.github/workflows/scripts/create-github-release.sh` to include the new agent's packages: -```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 +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" ``` -#### 5. Update Agent Context Scripts +**Skills agent (Codex):** -##### Bash script (`scripts/bash/update-agent-context.sh`) +```python +"""Codex CLI integration — skills-based agent.""" -Add file variable: +from __future__ import annotations -```bash -WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" -``` +from ..base import IntegrationOption, SkillsIntegration -Add to case statement: -```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 +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)", + ), + ] ``` -##### PowerShell script (`scripts/powershell/update-agent-context.ps1`) +#### Required fields -Add file variable: +| 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"`) | -```powershell -$windsurfFile = Join-Path $repoRoot '.windsurf/rules/specify-rules.md' -``` - -Add to switch statement: - -```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 - } -} -``` +**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"`). -#### 6. Update CLI Tool Checks (Optional) +### 3. Register it -For agents that require CLI tools, add checks in the `check()` command and agent validation: +In `src/specify_cli/integrations/__init__.py`, add one import and one `_register()` call inside `_register_builtins()`. Both lists are alphabetical: ```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 +def _register_builtins() -> None: + # -- Imports (alphabetical) ------------------------------------------- + from .claude import ClaudeIntegration + # ... + from .newagent import NewAgentIntegration # ← add import + # ... + + # -- Registration (alphabetical) -------------------------------------- + _register(ClaudeIntegration()) + # ... + _register(NewAgentIntegration()) # ← add registration + # ... ``` -**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. - -## Important Design Decisions +### 4. Context file behavior -### Using Actual CLI Tool Names as Keys +Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate. -**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. +Only add custom setup logic when the agent needs non-standard behavior. Most integrations do not need wrapper scripts or separate context-update dispatch code. -**Why this matters:** +### 5. Test it -- 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]" ] } @@ -288,7 +235,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`: @@ -298,63 +245,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 -- **Qwen Code**: `qwen` CLI -- **opencode**: `opencode` CLI -- **Codex CLI**: `codex` CLI (requires `--ai-skills`) -- **Junie**: `junie` CLI -- **Auggie CLI**: `auggie` CLI -- **CodeBuddy CLI**: `codebuddy` CLI -- **Qoder CLI**: `qodercli` CLI -- **Kiro CLI**: `kiro-cli` CLI -- **Amp**: `amp` CLI -- **SHAI**: `shai` CLI -- **Tabnine CLI**: `tabnine` CLI -- **Kimi Code**: `kimi` CLI -- **Mistral Vibe**: `vibe` CLI -- **Pi Coding Agent**: `pi` CLI -- **iFlow CLI**: `iflow` CLI -- **Forge**: `forge` CLI - -### IDE-Based Agents - -Work within integrated development environments: - -- **GitHub Copilot**: Built into VS Code/compatible editors -- **Cursor**: Built into Cursor IDE (`--ai cursor-agent`) -- **Windsurf**: Built into Windsurf IDE -- **Kilo Code**: Built into Kilo Code IDE -- **Roo Code**: Built into Roo Code IDE -- **IBM Bob**: Built into IBM Bob IDE -- **Trae**: Built into Trae IDE -- **Antigravity**: Built into Antigravity IDE (`--ai agy --ai-skills`) +--- ## Command File Formats ### Markdown Format -Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow, Forge - **Standard format:** ```markdown @@ -378,8 +278,6 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders. ### TOML Format -Used by: Gemini, Tabnine - ```toml description = "Command description" @@ -388,40 +286,33 @@ Command content with {SCRIPT} and {{args}} placeholders. """ ``` -## Directory Conventions - -- **CLI agents**: Usually `./commands/` -- **Singular command exception**: - - opencode: `.opencode/command/` (singular `command`, not `commands`) -- **Nested path exception**: - - Tabnine: `.tabnine/agent/commands/` (extra `agent/` segment) -- **Shared `.agents/` folder**: - - Amp: `.agents/commands/` (shared folder, not `.amp/`) - - Codex: `.agents/skills/` (shared folder; requires `--ai-skills`; invoked as `$speckit-`) -- **Skills-based exceptions**: - - Kimi Code: `.kimi/skills/` (skills, invoked as `/skill:speckit-`) -- **Prompt-based exceptions**: - - Kiro CLI: `.kiro/prompts/` - - Pi: `.pi/prompts/` - - Mistral Vibe: `.vibe/prompts/` -- **Rules-based exceptions**: - - Trae: `.trae/rules/` -- **IDE agents**: Follow IDE-specific patterns: - - Copilot: `.github/agents/` - - Cursor: `.cursor/commands/` - - Windsurf: `.windsurf/workflows/` - - Kilo Code: `.kilocode/workflows/` - - Roo Code: `.roo/commands/` - - IBM Bob: `.bob/commands/` - - Antigravity: `.agent/skills/` (`--ai-skills` required; `.agent/commands/` is deprecated) +### YAML Format + +Used by: Goose + +```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}}` -- **Forge-specific**: `{{parameters}}` (uses custom parameter syntax) +- **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) @@ -442,6 +333,24 @@ Implementation: Extends `IntegrationBase` with custom `setup()` method that: 2. Generates companion `.prompt.md` files 3. Merges VS Code settings +**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 + +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 + +# 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: @@ -455,42 +364,29 @@ Implementation: Extends `MarkdownIntegration` with custom `setup()` method that: 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` (similar to `opencode`/`codex`/`pi`) and lists `forge` in their usage/help text - -### Standard Markdown Agents -Most agents (Bob, Claude, Windsurf, etc.) use `MarkdownIntegration`: -- Simple subclass with just `key`, `config`, `registrar_config` set -- Inherits standard processing from `MarkdownIntegration.setup()` -- No custom processing needed +### Goose Integration -## Testing New Agent 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 -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 +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. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there ## Common Pitfalls -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). - -## Future Considerations - -When adding new agents: - -- 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 2237f7fbf0..4aef9210bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,297 @@ +## [0.8.7] - 2026-05-07 + +### Changed + +- feat: add agent-orchestrator to community extension catalog (#2236) +- chore: update extension versions in community catalog (#2468) +- fix(goose): Declare args parameter in generated recipes (#2402) +- feat: Add lingma support (#2348) +- docs: Add uv installation guide and inline callouts (#2465) +- Add fx-to-dotnet to community extension catalog (#2471) +- fix: default non-interactive init to copilot integration (#2414) +- fix(forge): use hyphen notation for command refs in Forge integration (#2462) +- feat(catalog): add Cost Tracker (cost) community extension (#2448) +- chore: release 0.8.6, begin 0.8.7.dev0 development (#2463) + +## [0.8.6] - 2026-05-06 + +### Changed + +- Load constitution context in `/speckit.implement` to enforce governance during implementation (#2460) +- feat: improve catalog submission templates and CODEOWNERS (#2401) +- fix: validate URL scheme in build_github_request (#2449) +- Add Architecture Guard to community catalog (#2430) +- Add multi-model-review extension to community catalog (#2446) +- Update Ralph Loop to v1.0.2 (#2435) +- Pin GitHub Actions by SHA (#2441) +- fix(workflows): require project for catalog list (#2436) +- Add agent-parity-governance to community catalog (#2382) +- chore: release 0.8.5, begin 0.8.6.dev0 development (#2447) + +## [0.8.5] - 2026-05-04 + +### Changed + +- feat(presets): add Spec2Cloud preset for Azure deployment workflow (#2413) +- update security-review and memory-md extensions to latest versions (#2445) +- fix: honor template overrides for tasks-template (#2278) (#2292) +- Add token-analyzer to community catalog (#2433) +- docs: add April 2026 newsletter (#2434) +- feat: emit init-time notice for git extension default change (#2165) (#2432) +- Update DyanGalih(Memory Hub and Security Review) community extensions (#2429) +- Support controlled multi-install for safe AI agent integrations (#2389) +- chore(integrations): clean up docs and project guard (#2428) +- chore: release 0.8.4, begin 0.8.5.dev0 development (#2431) + +## [0.8.4] - 2026-05-01 + +### Changed + +- fix(specify): correct self-referencing step number in validation flow (#2152) +- chore(deps): bump DavidAnson/markdownlint-cli2-action (#2425) +- Add security-governance to community catalog (#2386) +- Add cross-platform-governance to community catalog (#2384) +- Add architecture-governance to community catalog (#2383) +- Add a11y-governance to community catalog (#2381) +- feat(extensions): add Spec2Cloud extension for Azure deployment workflow (#2412) +- fix: migrate extension commands on integration switch (#2404) +- feat: add Squad Bridge extension to community catalog (#2417) +- chore: release 0.8.3, begin 0.8.4.dev0 development (#2418) + +## [0.8.3] - 2026-04-29 + +### Changed + +- Add Work IQ extension to community catalog (#2415) +- feat(integrations): add Devin for Terminal skills-based integration (#2364) +- fix: include --from git+... in upgrade hint to avoid PyPI squat package (#2411) +- fix: dispatch opencode commands via run (#2410) +- feat: add catalog discovery CLI commands (#2360) +- update security review extension catalog to v1.3.0 (#2374) +- chore(catalog): bump v-model extension to v0.6.0 (#2399) +- feat: add threatmodel extension to community catalog (#2369) +- Add isaqb-architecture-governance to community catalog (#2385) +- chore: release 0.8.2, begin 0.8.3.dev0 development (#2397) + +## [0.8.2] - 2026-04-28 + +### Changed + +- Add MarkItDown Document Converter extension to community catalog (#2390) +- feat: Speckit preset fiction book v1.7 - Support for RAG (Chroma DB) offline semantic search (#2367) +- fix(extensions): use explicit UTF-8 encoding when reading manifest YAML (#2370) +- catalog: add m365 community extension +- docs: replace deprecated --ai flag with --integration in all documentation (#2359) +- feat(extensions,presets): authenticate GitHub-hosted catalog and download requests with GITHUB_TOKEN/GH_TOKEN (#2331) +- Update extensify to v1.1.0 in community catalog (#2337) +- feat(init): deprecate --no-git flag, gate deprecations at v0.10.0 (#2357) +- Add Spec Orchestrator extension to community catalog (#2350) +- chore: release 0.8.1, begin 0.8.2.dev0 development (#2356) + +## [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 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/CONTRIBUTING.md b/CONTRIBUTING.md index 9044ef5ff9..5188d70a71 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 @@ -44,8 +44,7 @@ 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. -For the detailed test workflow, command-selection prompt, and PR reporting template, see [`TESTING.md`](./TESTING.md). -Activate the project virtual environment (see the Setup block in [`TESTING.md`](./TESTING.md)), 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. +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: @@ -69,34 +68,99 @@ When working on spec-kit: For the smoothest review experience, validate changes in this order: -1. **Run focused automated checks first** — use the quick verification commands in [`TESTING.md`](./TESTING.md) to catch packaging, scaffolding, and configuration regressions early. -2. **Run manual workflow tests second** — if your change affects slash commands or the developer workflow, follow [`TESTING.md`](./TESTING.md) to choose the right commands, run them in an agent, and capture results for your PR. -3. **Use local release packages when debugging packaged output** — if you need to inspect the exact files CI-style packaging produces, generate local release packages as described below. +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. -### Testing template and command changes locally +### Automated checks -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: +#### Agent configuration and wiring consistency -1. **Create release packages** +```bash +uv run python -m pytest tests/test_agent_config_consistency.py -q +``` - Run the following command to generate the local packages: +Run this when you change agent metadata, context update scripts, or integration wiring. - ```bash - ./.github/workflows/scripts/create-release-packages.sh v1.0.0 - ``` +### Manual testing -2. **Copy the relevant package to your test project** +#### Testing setup - ```bash - cp -r .genreleases/sdd-copilot-package-sh/. / - ``` +```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. -3. **Open and test the agent** +# Initialize a test project using your local changes +uv run specify init /speckit-test --integration +cd /speckit-test - Navigate to your test project folder and open the agent to verify your implementation. +# Open in your agent +``` -If you only need to validate generated file structure and content before doing manual agent testing, start with the focused automated checks in [`TESTING.md`](./TESTING.md). Keep this section for the cases where you need to inspect the exact packaged output locally. +#### Manual testing process + +Any change that affects a slash command's behavior requires manually testing that command through a coding 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: + +- 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 + +Include prerequisite tests (e.g., T5 requires T3 requires T1). + +Output in this format: + +### Test selection reasoning + +| Changed file | Affects | Test | Why | +|---|---|---|---| +| (path) | (command) | T# | (reason) | + +### Required tests + +Number each test sequentially (T1, T2, ...). List prerequisite tests first. + +- T1: /speckit.command — (reason) +- T2: /speckit.command — (reason) +~~~ ## AI contributions in Spec Kit diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index dc35bc6fe0..946e071e31 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -11,8 +11,7 @@ Spec Kit is a toolkit for spec-driven development. At its core, it is a coordina | [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, and required development practices. | -| [TESTING.md](TESTING.md) | Validation strategy and testing procedures. | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, testing, and required development practices. | **Main repository components:** diff --git a/EOF b/EOF new file mode 100644 index 0000000000..e69de29bb2 diff --git a/README.md b/README.md index 711abb341e..79940074e4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - [🎨 Community Presets](#-community-presets) - [🚶 Community Walkthroughs](#-community-walkthroughs) - [🛠️ Community Friends](#️-community-friends) -- [🤖 Supported AI Agents](#-supported-ai-agents) +- [🤖 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) @@ -50,28 +50,43 @@ Spec-Driven Development **flips the script** on traditional software development 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): +> [!NOTE] +> The `uv tool install` commands below require **[uv](https://docs.astral.sh/uv/)** — a fast Python package manager. If you see `command not found: uv`, [install uv first](./docs/install/uv.md). The `pipx` alternative does not require uv. + ```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 use the tool directly: +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 claude +specify init . --integration copilot # or -specify init --here --ai claude +specify init --here --integration copilot # Check installed tools specify check @@ -81,6 +96,7 @@ To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed inst ```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 @@ -92,9 +108,9 @@ Run directly without installing: 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 claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --integration copilot # or -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot ``` **Benefits of persistent installation:** @@ -110,7 +126,7 @@ If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-G ### 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. +Launch your coding agent 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. @@ -161,7 +177,7 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c ## 🧩 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. +> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — 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/).** @@ -182,19 +198,33 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | +| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) | +| 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) | +| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) | | 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) | +| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) | | 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) | +| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) | | 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) | @@ -205,285 +235,134 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | +| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) | +| 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 | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `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) | +| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) | +| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) | +| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) | | 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) | +| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) | | 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: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | +| 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) | +| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/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) | -| 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) | -| 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) | +| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [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 Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) | +| 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) | +| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | +| 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) | +| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) | +| 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) | +| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) | | 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) | +| Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) | +| 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 -> [!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`](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) | -| 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) | -| 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) | -| 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](presets/PUBLISHING.md). - -## 🚶 Community Walkthroughs +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 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. +> 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. -- **[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. +To submit your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md). -- **[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. +## 🚶 Community Walkthroughs -- **[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. +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 -> [!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. - -## 🤖 Supported AI Agents - -| Agent | Support | Notes | -| ------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| [Qoder CLI](https://qoder.com/cli) | ✅ | | -| [Kiro CLI](https://kiro.dev/docs/cli/) | ✅ | Use `--ai kiro-cli` (alias: `--ai kiro`) | -| [Amp](https://ampcode.com/) | ✅ | | -| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | | -| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | Installs skills in `.claude/skills`; invoke spec-kit as `/speckit-constitution`, `/speckit-plan`, etc. | -| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | | -| [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-`. | -| [Cursor](https://cursor.sh/) | ✅ | | -| [Forge](https://forgecode.dev/) | ✅ | CLI tool: `forge` | -| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | | -| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | | -| [IBM Bob](https://www.ibm.com/products/bob) | ✅ | IDE-based agent with slash command support | -| [Jules](https://jules.google.com/) | ✅ | | -| [Kilo Code](https://github.com/Kilo-Org/kilocode) | ✅ | | -| [opencode](https://opencode.ai/) | ✅ | | -| [Pi Coding Agent](https://pi.dev) | ✅ | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | -| [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | | -| [Roo Code](https://roocode.com/) | ✅ | | -| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | -| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | | -| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | | -| [Kimi Code](https://code.kimi.com/) | ✅ | | -| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | ✅ | | -| [Windsurf](https://windsurf.com/) | ✅ | | -| [Junie](https://junie.jetbrains.com/) | ✅ | | -| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` | -| [Trae](https://www.trae.ai/) | ✅ | | -| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | - -## 🔧 Specify CLI Reference - -The `specify` command supports the following options: - -### Commands +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. -| Command | Description | -| ------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `init` | Initialize a new Specify project from the latest template | -| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, etc.) | +## 🤖 Supported AI Coding Agent Integrations -### `specify init` Arguments & Options - -| Argument/Option | Type | Description | -| ---------------------- | -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, or `generic` (requires `--ai-commands-dir`) | -| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | -| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | -| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | -| `--no-git` | Flag | Skip git repository initialization | -| `--here` | Flag | Initialize project in the current directory instead of creating a new one | -| `--force` | Flag | Force merge/overwrite when initializing in current directory (skip confirmation) | -| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | -| `--debug` | Flag | Enable detailed debug output for troubleshooting | -| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | -| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`). Extension commands are also auto-registered as skills when extensions are added later. | -| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`, …, `1000`, … — expands beyond 3 digits automatically) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts | - -### Examples - -```bash -# Basic project initialization -specify init my-project +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. -# Initialize with specific AI assistant -specify init my-project --ai claude +Run `specify integration list` to see all available integrations in your installed version. -# Initialize with Cursor support -specify init my-project --ai cursor-agent +## Available Slash Commands -# Initialize with Qoder support -specify init my-project --ai qodercli - -# Initialize with Windsurf support -specify init my-project --ai windsurf - -# Initialize with Kiro CLI support -specify init my-project --ai kiro-cli - -# Initialize with Amp support -specify init my-project --ai amp - -# Initialize with SHAI support -specify init my-project --ai shai - -# Initialize with Mistral Vibe support -specify init my-project --ai vibe - -# Initialize with IBM Bob support -specify init my-project --ai bob - -# Initialize with Pi Coding Agent support -specify init my-project --ai pi - -# Initialize with Codex CLI support -specify init my-project --ai codex --ai-skills - -# Initialize with Antigravity support -specify init my-project --ai agy --ai-skills - -# Initialize with Forge support -specify init my-project --ai forge - -# Initialize with an unsupported agent (generic / bring your own agent) -specify init my-project --ai generic --ai-commands-dir .myagent/commands/ - -# Initialize with PowerShell scripts (Windows/cross-platform) -specify init my-project --ai copilot --script ps - -# Initialize in current directory -specify init . --ai copilot -# or use the --here flag -specify init --here --ai copilot - -# Force merge into current (non-empty) directory without confirmation -specify init . --force --ai copilot -# or -specify init --here --force --ai copilot - -# Skip git initialization -specify init my-project --ai gemini --no-git - -# Enable debug output for troubleshooting -specify init my-project --ai claude --debug - -# Use GitHub token for API requests (helpful for corporate environments) -specify init my-project --ai claude --github-token ghp_your_token_here - -# Claude Code installs skills with the project by default -specify init my-project --ai claude - -# Initialize in current directory with agent skills -specify init --here --ai gemini --ai-skills - -# Use timestamp-based branch numbering (useful for distributed teams) -specify init my-project --ai claude --branch-numbering timestamp - -# Check system requirements -specify check -``` - -### Available Slash Commands - -After running `specify init`, your AI coding agent will have access to these structured development commands. - -Most agents expose the traditional dotted slash commands shown below, like `/speckit.plan`. - -Claude Code installs spec-kit as skills and invokes them as `/speckit-constitution`, `/speckit-specify`, `/speckit-plan`, `/speckit-tasks`, and `/speckit-implement`. - -For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`. +After running `specify init`, your AI coding agent will have access to these slash commands for structured development. For integrations that support skills mode, passing `--integration --integration-options="--skills"` installs agent skills instead of slash-command prompt files. #### Core Commands Essential commands for the Spec-Driven Development workflow: -| Command | Description | -| ----------------------- | ------------------------------------------------------------------------ | -| `/speckit.constitution` | Create or update project governing principles and development guidelines | -| `/speckit.specify` | Define what you want to build (requirements and user stories) | -| `/speckit.plan` | Create technical implementation plans with your chosen tech stack | -| `/speckit.tasks` | Generate actionable task lists for implementation | -| `/speckit.implement` | Execute all tasks to build the feature according to the plan | +| 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 | Description | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `/speckit.clarify` | Clarify underspecified areas (recommended before `/speckit.plan`; formerly `/quizme`) | -| `/speckit.analyze` | Cross-artifact consistency & coverage analysis (run after `/speckit.tasks`, before `/speckit.implement`) | -| `/speckit.checklist` | Generate custom quality checklists that validate requirements completeness, clarity, and consistency (like "unit tests for English") | +| 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") | -### Environment Variables +## 🔧 Specify CLI Reference -| 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 you're working with prior to using `/speckit.plan` or follow-up commands. | +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: -```mermaid -block-beta - columns 1 - overrides["⬆ Highest priority\nProject-Local Overrides\n.specify/templates/overrides/"] - presets["Presets — Customize core & extensions\n.specify/presets//templates/"] - extensions["Extensions — Add new capabilities\n.specify/extensions//templates/"] - core["Spec Kit Core — Built-in SDD commands & templates\n.specify/templates/\n⬇ Lowest priority"] - - style overrides fill:transparent,stroke:#999 - style presets fill:transparent,stroke:#4a9eda - style extensions fill:transparent,stroke:#4a9e4a - style core fill:transparent,stroke:#e6a817 -``` +| 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. **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. +- **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 @@ -499,7 +378,7 @@ 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 README](./extensions/README.md) for the full guide and how to build and publish your own. Browse the [community extensions](#-community-extensions) above for what's available. +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 @@ -515,7 +394,7 @@ 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 README](./presets/README.md) for the full guide, including resolution order, priority, and how to create your own. +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 @@ -573,8 +452,8 @@ Our research and experimentation focus on: ## 🔧 Prerequisites - **Linux/macOS/Windows** -- [Supported](#-supported-ai-agents) AI coding agent. -- [uv](https://docs.astral.sh/uv/) for package management +- [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) @@ -612,37 +491,37 @@ 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: +In an interactive terminal, you will be prompted to select the coding agent integration you are using. In non-interactive sessions, such as CI or piped runs, `specify init` defaults to GitHub Copilot unless you pass `--integration`. You can also proactively specify the integration directly in the terminal: ```bash -specify init --ai claude -specify init --ai gemini -specify init --ai copilot +specify init --integration copilot +specify init --integration gemini +specify init --integration codex # Or in current directory: -specify init . --ai claude -specify init . --ai codex --ai-skills +specify init . --integration copilot +specify init . --integration codex --integration-options="--skills" # or use --here flag -specify init --here --ai claude -specify init --here --ai codex --ai-skills +specify init --here --integration copilot +specify init --here --integration codex --integration-options="--skills" # Force merge into a non-empty current directory -specify init . --force --ai claude +specify init . --force --integration copilot # or -specify init --here --force --ai claude +specify init --here --force --integration 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, 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: +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 claude --ignore-agent-tools +specify init --integration 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`. +Go to the project folder and run your coding agent. In our example, we're using `claude`. ![Bootstrapping Claude Code environment](./media/bootstrap-claude-code.gif) @@ -654,7 +533,7 @@ The first step should be establishing your project's governing principles using /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. ``` -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. +This step creates or updates the `.specify/memory/constitution.md` file with your project's foundational guidelines that the coding agent will reference during specification, planning, and implementation phases. ### **STEP 2:** Create project specifications @@ -862,9 +741,9 @@ The `/speckit.implement` command will: - 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. +> The coding 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. +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 coding agent for resolution.
diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index 1fa6b1c881..0000000000 --- a/TESTING.md +++ /dev/null @@ -1,133 +0,0 @@ -# Testing Guide - -This document is the detailed testing companion to [`CONTRIBUTING.md`](./CONTRIBUTING.md). - -Use it for three things: - -1. running quick automated checks before manual testing, -2. manually testing affected slash commands through an AI agent, and -3. capturing the results in a PR-friendly format. - -Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR. - -## Recommended order - -1. **Sync your environment** — install the project and test dependencies. -2. **Run focused automated checks** — especially for packaging, scaffolding, agent config, and generated-file changes. -3. **Run manual agent tests** — for any affected slash commands. -4. **Paste results into your PR** — include both command-selection reasoning and manual test results. - -## Quick automated checks - -Run these before manual testing when your change affects packaging, scaffolding, templates, release artifacts, or agent wiring. - -### Environment setup - -```bash -cd -uv sync --extra test -source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1 -``` - -### Generated package structure and content - -```bash -uv run python -m pytest tests/test_core_pack_scaffold.py -q -``` - -This validates the generated files that CI-style packaging depends on, including directory layout, file names, frontmatter/TOML validity, placeholder replacement, `.specify/` path rewrites, and parity with `create-release-packages.sh`. - -### Agent configuration and release wiring consistency - -```bash -uv run python -m pytest tests/test_agent_config_consistency.py -q -``` - -Run this when you change agent metadata, release scripts, context update scripts, or artifact naming. - -### Optional single-agent packaging spot check - -```bash -AGENTS=copilot SCRIPTS=sh ./.github/workflows/scripts/create-release-packages.sh v1.0.0 -``` - -Inspect `.genreleases/sdd-copilot-package-sh/` and the matching ZIP in `.genreleases/` when you want to review the exact packaged output for one agent/script combination. - -## Manual testing process - -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 [Setup](#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. - -## 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 /tmp/speckit-test --ai --offline -cd /tmp/speckit-test - -# Open in your agent -``` - -If you are testing the packaged output rather than the live source tree, create a local release package first as described in [`CONTRIBUTING.md`](./CONTRIBUTING.md). - -## 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 TESTING.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: - -- 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 - -Include prerequisite tests (e.g., T5 requires T3 requires T1). - -Output in this format: - -### Test selection reasoning - -| Changed file | Affects | Test | Why | -|---|---|---|---| -| (path) | (command) | T# | (reason) | - -### Required tests - -Number each test sequentially (T1, T2, ...). List prerequisite tests first. - -- T1: /speckit.command — (reason) -- T2: /speckit.command — (reason) -~~~ 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..6eb8019872 --- /dev/null +++ b/docs/community/presets.md @@ -0,0 +1,29 @@ +# Community Presets + +> [!NOTE] +> Community presets are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — 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 | +|--------|---------|----------|----------|-----| +| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) | +| Agent Parity Governance | Keeps shared AI-agent instructions aligned across project-defined agent guidance surfaces and documents intentional deviations | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) | +| 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) | +| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) | +| 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) | +| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) | +| 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, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) | +| 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) | +| Security Governance | Adds secure development governance: memory-safe-language preference, secure code generation, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/VEX/SLSA, OpenSSF Scorecard, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) | +| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | +| 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/install/uv.md b/docs/install/uv.md new file mode 100644 index 0000000000..21e6d23baf --- /dev/null +++ b/docs/install/uv.md @@ -0,0 +1,60 @@ +# Installing uv + +[uv](https://docs.astral.sh/uv/) is a fast Python package manager by [Astral](https://astral.sh/). Spec Kit uses `uv` (via `uvx` or `uv tool install`) to run the `specify` CLI without polluting your global Python environment. + +> [!NOTE] +> **Already have uv?** Run `uv --version` to confirm it is installed, then head back to the [Installation Guide](../installation.md). + +## Installation + +### macOS and Linux — Standalone Installer + +The quickest way to install uv on macOS or Linux is the official shell script: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +After the script finishes, follow any instructions printed by the installer to add uv to your `PATH`, then open a new terminal. + +### Windows — Standalone Installer + +Run the following in **Command Prompt or PowerShell**: + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +After the script finishes, open a new terminal so the `uv` binary is on your `PATH`. + +### macOS — Homebrew + +```bash +brew install uv +``` + +### Windows — WinGet + +```powershell +winget install --id=astral-sh.uv -e +``` + +### Windows — Scoop + +```powershell +scoop install uv +``` + +## Verification + +Confirm that uv is installed and on your `PATH`: + +```bash +uv --version +``` + +You should see output similar to `uv 0.x.y (...)`. + +## Further Reading + +For advanced options (self-update, proxy settings, uninstall, etc.) see the official [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/). diff --git a/docs/installation.md b/docs/installation.md index 5d560b6e33..86ad35559f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,16 +4,21 @@ - **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), [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 +- [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. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): +> [!NOTE] +> The `uvx` commands below require **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uvx`, [install uv first](./install/uv.md). The `pipx` alternative does not require uv. + ```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 @@ -22,6 +27,13 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z 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 @@ -30,16 +42,18 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here ``` -### Specify AI Agent +### Specify Integration -You can proactively specify your AI agent during initialization: +Interactive terminals prompt you to choose a coding agent integration during initialization. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot unless you pass `--integration`. + +You can proactively specify your coding agent integration during initialization: ```bash -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 +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration gemini +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration codebuddy +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration pi ``` ### Specify Script Type (Shell vs PowerShell) @@ -64,12 +78,20 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai claude --ignore-agent-tools +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration claude --ignore-agent-tools ``` ## Verification -After initialization, you should see the following commands available in your AI agent: +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 coding agent: - `/speckit.specify` - Create specifications - `/speckit.plan` - Generate implementation plans @@ -114,12 +136,10 @@ pip install --no-index --find-links=./dist specify-cli ```bash # Initialize a project — no GitHub access needed -specify init my-project --ai claude --offline +specify init my-project --integration claude ``` -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. +Bundled assets are used by default — no network access is required. > **Note:** Python 3.11+ is required. diff --git a/docs/local-development.md b/docs/local-development.md index 7fac06adf4..4776204d7d 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -20,7 +20,7 @@ You can execute the CLI via the module entrypoint without installing anything: ```bash # From repo root python -m src.specify_cli --help -python -m src.specify_cli init demo-project --ai claude --ignore-agent-tools --script sh +python -m src.specify_cli init demo-project --integration claude --ignore-agent-tools --script sh ``` If you prefer invoking the script file style (uses shebang): @@ -52,7 +52,7 @@ Re-running after code edits requires no reinstall because of editable mode. `uvx` can run from a local path (or a Git ref) to simulate user flows: ```bash -uvx --from . specify init demo-uvx --ai copilot --ignore-agent-tools --script sh +uvx --from . specify init demo-uvx --integration copilot --ignore-agent-tools --script sh ``` You can also point uvx at a specific branch without merging: @@ -69,14 +69,14 @@ If you're in another directory, use an absolute path instead of `.`: ```bash uvx --from /mnt/c/GitHub/spec-kit specify --help -uvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --ai copilot --ignore-agent-tools --script sh +uvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --integration copilot --ignore-agent-tools --script sh ``` Set an environment variable for convenience: ```bash export SPEC_KIT_SRC=/mnt/c/GitHub/spec-kit -uvx --from "$SPEC_KIT_SRC" specify init demo-env --ai copilot --ignore-agent-tools --script ps +uvx --from "$SPEC_KIT_SRC" specify init demo-env --integration copilot --ignore-agent-tools --script ps ``` (Optional) Define a shell function: @@ -123,21 +123,19 @@ When testing `init --here` in a dirty directory, create a temp workspace: ```bash mkdir /tmp/spec-test && cd /tmp/spec-test -python -m src.specify_cli init --here --ai claude --ignore-agent-tools --script sh # if repo copied here +python -m src.specify_cli init --here --integration claude --ignore-agent-tools --script sh # if repo copied here ``` 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 4b2c3c8807..0e6c0ab9d4 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 @@ -31,7 +42,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init **Security:** Restrict the file to owner-only access: +> ```bash +> chmod 600 ~/.specify/auth.json +> ``` + +Without this file, all HTTP requests are unauthenticated. + +## Fields + +Each entry in the `providers` array has the following fields: + +| Field | Required | Description | +|---|---|---| +| `hosts` | Yes | Array of hostnames this entry applies to. Supports exact hostnames, or a leading `*.` wildcard for subdomains only (for example, `*.visualstudio.com`). `*.visualstudio.com` matches `foo.visualstudio.com`, but not `visualstudio.com`. Other glob patterns such as `*github.com` or `gith?b.com` are not supported. | +| `provider` | Yes | Built-in provider key: `github` or `azure-devops`. | +| `auth` | Yes | Auth scheme (see below). | +| `token` | No | Token value (inline). Use `token_env` instead when possible. | +| `token_env` | No | Environment variable name to read the token from. | + +For `azure-ad` auth, additional fields are required: + +| Field | Required | Description | +|---|---|---| +| `tenant_id` | Yes | Azure AD tenant ID. | +| `client_id` | Yes | Service principal client ID. | +| `client_secret_env` | Yes | Environment variable containing the client secret. | + +Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes. + +## Providers and auth schemes + +### GitHub (`github`) + +| Scheme | Header | Use for | +|---|---|---| +| `bearer` | `Authorization: Bearer ` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens | + +**Example — PAT via environment variable:** + +```json +{ + "hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"], + "provider": "github", + "auth": "bearer", + "token_env": "GH_TOKEN" +} +``` + +### Azure DevOps (`azure-devops`) + +| Scheme | Header | Use for | +|---|---|---| +| `basic-pat` | `Authorization: Basic base64(:)` | Personal Access Tokens | +| `bearer` | `Authorization: Bearer ` | Pre-acquired OAuth / Azure AD tokens | +| `azure-cli` | `Authorization: Bearer ` | Token acquired via `az account get-access-token` | +| `azure-ad` | `Authorization: Bearer ` | Token acquired via OAuth2 client credentials flow | + +**Example — PAT via environment variable:** + +```json +{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "basic-pat", + "token_env": "AZURE_DEVOPS_PAT" +} +``` + +**Example — Azure CLI (interactive login):** + +```json +{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "azure-cli" +} +``` + +Requires `az login` to have been run beforehand. + +**Example — Azure AD service principal (CI/automation):** + +```json +{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "azure-ad", + "tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "client_secret_env": "AZURE_CLIENT_SECRET" +} +``` + +## Multiple entries + +You can configure multiple entries for different hosts or organizations: + +```json +{ + "providers": [ + { + "hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"], + "provider": "github", + "auth": "bearer", + "token_env": "GH_TOKEN" + }, + { + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "basic-pat", + "token_env": "AZURE_DEVOPS_PAT" + } + ] +} +``` + +## How it works + +1. For each outbound HTTP request, the URL hostname is matched against + the `hosts` patterns in `auth.json`. +2. If a match is found, the corresponding provider resolves the token + and attaches the appropriate `Authorization` header. +3. If the request receives a 401 or 403, the next matching entry is tried. +4. After all matching entries are exhausted, an unauthenticated request + is attempted as a final fallback. +5. On redirects, the `Authorization` header is stripped if the redirect + target leaves the entry's declared hosts — preventing credential + leakage to CDNs or third-party services. + +## Template + +A reference `auth.json` with GitHub pre-configured: + +```json +{ + "providers": [ + { + "hosts": [ + "github.com", + "api.github.com", + "raw.githubusercontent.com", + "codeload.github.com" + ], + "provider": "github", + "auth": "bearer", + "token_env": "GH_TOKEN" + } + ] +} +``` + +To use it: + +```bash +mkdir -p ~/.specify +# Copy the JSON above into ~/.specify/auth.json +chmod 600 ~/.specify/auth.json +``` diff --git a/docs/reference/core.md b/docs/reference/core.md new file mode 100644 index 0000000000..0b7b5b49bc --- /dev/null +++ b/docs/reference/core.md @@ -0,0 +1,85 @@ +# 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. + +> [!NOTE] +> The git extension is currently enabled by default during `specify init`. +> Starting in `v0.10.0`, it will require explicit opt-in. To add it after init, run `specify extension add git`. + +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. + +When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration ` to choose a different integration explicitly. + +### 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..42332b1fe7 --- /dev/null +++ b/docs/reference/integrations.md @@ -0,0 +1,189 @@ +# 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` | | +| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | +| [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` | +| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | +| [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. +When multiple integrations are installed, the list marks the default integration separately from the other installed integrations. +The list also shows whether each built-in integration is declared multi-install safe. + +## Install an Integration + +```bash +specify integration install +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--force` | Opt in to installing alongside integrations that are not declared multi-install safe | +| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | + +Installs the specified integration into the current project. If another integration is already installed, the command only proceeds automatically when all involved integrations are declared multi-install safe. Otherwise, use `switch` to replace the default integration or pass `--force` to explicitly opt in to multi-install. If the installation fails partway through, it automatically rolls back to a clean state. + +Installing an additional integration does not change the default integration. Use `specify integration use ` to change the default. + +> **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; when the target is already installed, overwrite managed shared templates while changing the default | +| `--integration-options` | Options for the target integration when it is not already installed | + +If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade --integration-options ...` first, then `use `. + +## Use an Installed Integration + +```bash +specify integration use +``` + +| Option | Description | +| --------- | --------------------------------------------------- | +| `--force` | Overwrite managed shared templates while changing the default | + +Sets the default integration without uninstalling any other installed integrations. This also refreshes managed shared templates so command references match the new default integration's invocation style. Modified or untracked shared templates are preserved unless `--force` is used. + +## 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 an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. 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. Shared templates stay aligned with the default integration even when upgrading a non-default integration. + +## 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 install multiple integrations in the same project? + +Yes, but it is intended for team portability rather than the default workflow. Multiple integrations are allowed automatically only when the installed integration and the new integration are declared multi-install safe by Spec Kit. For other combinations, pass `--force` to acknowledge that multiple agents may see unrelated agent-specific instructions or commands. + +Spec Kit tracks one default integration in `.specify/integration.json` with `default_integration`, all installed integrations with `installed_integrations`, per-integration runtime settings with `integration_settings`, and a dedicated `integration_state_schema` for future state migrations. The legacy `integration` field remains as an alias for the default integration. + +### Which integrations are multi-install safe? + +An integration is multi-install safe when it uses isolated agent directories, a dedicated context file that does not collide with another safe integration, stable command invocation settings, and a separate install manifest. Shared Spec Kit templates remain aligned to the single default integration. + +The currently declared multi-install safe integrations are: + +| Key | Isolation | +| --- | --------- | +| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` | +| `claude` | `.claude/skills`, `CLAUDE.md` | +| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` | +| `codex` | `.agents/skills`, `AGENTS.md` | +| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` | +| `gemini` | `.gemini/commands`, `GEMINI.md` | +| `iflow` | `.iflow/commands`, `IFLOW.md` | +| `junie` | `.junie/commands`, `.junie/AGENTS.md` | +| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` | +| `kimi` | `.kimi/skills`, `KIMI.md` | +| `qodercli` | `.qoder/commands`, `QODER.md` | +| `qwen` | `.qwen/commands`, `QWEN.md` | +| `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` | +| `shai` | `.shai/commands`, `SHAI.md` | +| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` | +| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` | +| `windsurf` | `.windsurf/workflows`, `.windsurf/rules/specify-rules.md` | + +Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`. + +### 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 an installed integration's managed files. Use `switch` when you want to replace the current default with another integration; if the target is already installed, `switch` behaves like `use`. 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..4101ae742d 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -11,9 +11,37 @@ href: quickstart.md - name: Upgrade href: upgrade.md + - name: Install uv + href: install/uv.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 cd5cc124fe..ec87662cbc 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -9,7 +9,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@vX.Y.Z` | Get latest CLI features without touching project files | -| **Project Files** | `specify init --here --force --ai ` | Update slash commands, templates, and scripts in your project | +| **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 --integration ` | Update slash commands, templates, and scripts in your project | | **Both** | Run CLI upgrade, then project update | Recommended for major version updates | --- @@ -31,7 +32,15 @@ uv tool install specify-cli --force --from git+https://github.com/github/spec-ki Specify the desired release tag: ```bash -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration 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 @@ -53,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? @@ -73,15 +82,15 @@ The `specs/` directory is completely excluded from template packages and will ne Run this inside your project directory: ```bash -specify init --here --force --ai +specify init --here --force --integration ``` -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:** ```bash -specify init --here --force --ai copilot +specify init --here --force --integration copilot ``` ### Understanding the `--force` flag @@ -94,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. @@ -113,7 +124,7 @@ With `--force`, it skips the confirmation and proceeds immediately. cp .specify/memory/constitution.md .specify/memory/constitution-backup.md # 2. Run the upgrade -specify init --here --force --ai copilot +specify init --here --force --integration copilot # 3. Restore your customized constitution mv .specify/memory/constitution-backup.md .specify/memory/constitution.md @@ -126,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 ``` @@ -170,7 +182,7 @@ Restart your IDE to refresh the command list. uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git # Update project files to get new commands -specify init --here --force --ai copilot +specify init --here --force --integration copilot # Restore your constitution if customized git restore .specify/memory/constitution.md @@ -187,7 +199,7 @@ cp -r .specify/templates /tmp/templates-backup uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git # 3. Update project -specify init --here --force --ai copilot +specify init --here --force --integration copilot # 4. Restore customizations mv /tmp/constitution-backup.md .specify/memory/constitution.md @@ -220,7 +232,7 @@ If you initialized your project with `--no-git`, you can still upgrade: cp .specify/memory/constitution.md /tmp/constitution-backup.md # Run upgrade -specify init --here --force --ai copilot --no-git +specify init --here --force --integration copilot --no-git # Restore customizations mv /tmp/constitution-backup.md .specify/memory/constitution.md @@ -241,13 +253,13 @@ The `--no-git` flag tells Spec Kit to **skip git repository initialization**. Th **During initial setup:** ```bash -specify init my-project --ai copilot --no-git +specify init my-project --integration copilot --no-git ``` **During upgrade:** ```bash -specify init --here --force --ai copilot --no-git +specify init --here --force --integration copilot --no-git ``` ### What `--no-git` does NOT do @@ -292,7 +304,7 @@ 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 .cursor/skills/ # Cursor ls -la .pi/prompts/ # Pi Coding Agent ``` @@ -355,7 +367,7 @@ Only Spec Kit infrastructure files: - **Use `--force` flag** - Skip this confirmation entirely: ```bash - specify init --here --force --ai copilot + specify init --here --force --integration copilot ``` **When you see this warning:** @@ -401,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/`, `.pi/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:** diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index dfc1125228..5f24e71f0c 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -528,11 +528,9 @@ specify extension add --from https://github.com/.../spec-kit-my Submit to the community catalog for public discovery: -1. **Fork** spec-kit repository -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: +1. **Create a GitHub release** for your extension +2. **File an issue** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template +3. **After review**, a maintainer updates the catalog and 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) @@ -669,7 +667,7 @@ hooks: **Error**: `Extension requires spec-kit >=0.2.0` -- **Fix**: Update spec-kit with `uv tool install specify-cli --force` +- **Fix**: Update spec-kit with `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git`. The bare `specify-cli` package on PyPI is a different, unrelated project — installing it without `--from git+...` will give you a stub CLI that does not include `extension`, `preset`, or other spec-kit commands. **Error**: `Command file not found` diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index 1433738743..be5b375241 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -7,9 +7,8 @@ This guide explains how to publish your extension to the Spec Kit extension cata 1. [Prerequisites](#prerequisites) 2. [Prepare Your Extension](#prepare-your-extension) 3. [Submit to Catalog](#submit-to-catalog) -4. [Verification Process](#verification-process) -5. [Release Workflow](#release-workflow) -6. [Best Practices](#best-practices) +4. [Release Workflow](#release-workflow) +5. [Best Practices](#best-practices) --- @@ -133,222 +132,46 @@ specify extension add --from https://github.com/your-org/spec-k 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`. +**For extension publishing**: All community extensions are listed in `extensions/catalog.community.json`. Users browse this catalog and copy extensions they trust into their own `catalog.json`. -### 1. Fork the spec-kit Repository +### How to Submit -```bash -# Fork on GitHub -# 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 Community Catalog - -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/github/spec-kit/main/extensions/catalog.community.json", - "extensions": { - "your-extension": { - "name": "Your Extension Name", - "id": "your-extension", - "description": "Brief description of your extension", - "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", - "documentation": "https://github.com/your-org/spec-kit-your-extension/blob/main/docs/", - "changelog": "https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0", - "tools": [ - { - "name": "required-mcp-tool", - "version": ">=1.0.0", - "required": true - } - ] - }, - "provides": { - "commands": 3, - "hooks": 1 - }, - "tags": [ - "category", - "tool-name", - "feature" - ], - "verified": false, - "downloads": 0, - "stars": 0, - "created_at": "2026-01-28T00:00:00Z", - "updated_at": "2026-01-28T00:00:00Z" - } - } -} -``` - -**Important**: - -- Set `verified: false` (maintainers will verify) -- Set `downloads: 0` and `stars: 0` (auto-updated later) -- Use current timestamp for `created_at` and `updated_at` -- Update the top-level `updated_at` to current time +To submit your extension to the community catalog, file a new issue using the **[Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml)** template. The template collects all required metadata, including: -### 3. Update Community Extensions Table +- Extension ID, name, and version +- Description, author, and license +- Repository, download URL, and documentation links +- Required Spec Kit version and any tool dependencies +- Number of commands and hooks +- Tags and key features +- Testing confirmation -Add your extension to the Community Extensions table in the project root `README.md`: +> [!IMPORTANT] +> Do **not** open a pull request directly to edit `extensions/catalog.community.json`. All community extension submissions must go through the issue template so a maintainer can review the entry and update the catalog. -```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. +### What Happens After You Submit -### 4. Submit Pull Request +1. Your issue is automatically labeled and assigned to a maintainer for review +2. A maintainer verifies that the catalog entry is complete and correctly formatted +3. Once approved, the maintainer adds your extension to `extensions/catalog.community.json` and the Community Extensions table in the README +4. Your extension becomes discoverable via `specify extension search` -```bash -# Create a branch -git checkout -b add-your-extension - -# Commit your changes -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 -- Author: Your Name -- Description: Brief description -" +### What Maintainers Check -# Push to your fork -git push origin add-your-extension +- The catalog entry fields are complete and correctly formatted +- The download URL is accessible +- The repository exists and contains an `extension.yml` manifest -# Create Pull Request on GitHub -# https://github.com/github/spec-kit/compare -``` - -**Pull Request Template**: - -```markdown -## Extension Submission - -**Extension Name**: Your Extension Name -**Extension ID**: your-extension -**Version**: 1.0.0 -**Author**: Your Name -**Repository**: https://github.com/your-org/spec-kit-your-extension - -### Description -Brief description of what your extension does. - -### Checklist -- [x] Valid extension.yml manifest -- [x] README.md with installation and usage docs -- [x] LICENSE file included -- [x] GitHub release created (v1.0.0) -- [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: -- macOS 13.0+ with spec-kit 0.1.0 -- Project: [Your test project] - -### Additional Notes -Any additional context or notes for reviewers. -``` +> [!NOTE] +> Maintainers do **not** review, audit, or test the extension code itself. ---- - -## Verification Process - -### What Happens After Submission - -1. **Automated Checks** (if available): - - Manifest validation - - Download URL accessibility - - Repository existence - - License file presence - -2. **Manual Review**: - - Code quality review - - Security audit - - Functionality testing - - Documentation review - -3. **Verification**: - - If approved, `verified: true` is set - - Extension appears in `specify extension search --verified` - -### Verification Criteria - -To be verified, your extension must: - -✅ **Functionality**: - -- Works as described in documentation -- All commands execute without errors -- No breaking changes to user workflows - -✅ **Security**: - -- No known vulnerabilities -- No malicious code -- Safe handling of user data -- Proper validation of inputs - -✅ **Code Quality**: - -- Clean, readable code -- Follows extension best practices -- Proper error handling -- Helpful error messages - -✅ **Documentation**: - -- Clear installation instructions -- Usage examples -- Troubleshooting section -- Accurate description +### Typical Review Timeline -✅ **Maintenance**: +- **Review**: 3-7 business days -- Active repository -- Responsive to issues -- Regular updates -- Semantic versioning followed +### Updating an Existing Extension -### Typical Review Timeline - -- **Automated checks**: Immediate (if implemented) -- **Manual review**: 3-7 business days -- **Verification**: After successful review +To update an extension that is already in the catalog (e.g., for a new version), file a new **[Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml)** issue with the updated version, download URL, and any other changed fields. Mention in the issue that this is an update to an existing entry. --- @@ -385,26 +208,7 @@ When releasing a new version: # Create release on GitHub ``` -4. **Update catalog**: - - ```bash - # Fork spec-kit repo (or update existing fork) - cd spec-kit - - # Update extensions/catalog.json - jq '.extensions["your-extension"].version = "1.1.0"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - jq '.extensions["your-extension"].download_url = "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.1.0.zip"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - jq '.extensions["your-extension"].updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - jq '.updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - - # Submit PR - git checkout -b update-your-extension-v1.1.0 - git add extensions/catalog.json - git commit -m "Update your-extension to v1.1.0" - git push origin update-your-extension-v1.1.0 - ``` - -5. **Submit update PR** with changelog in description +4. **File an update submission** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template with the new version and download URL. Mention in the issue that this is an update to an existing entry. --- @@ -473,9 +277,9 @@ A: The main catalog is for public extensions only. For private extensions: - Users add your catalog: `specify extension add-catalog https://your-domain.com/catalog.json` - Not yet implemented - coming in Phase 4 -### Q: How long does verification take? +### Q: How long does review take? -A: Typically 3-7 business days for initial review. Updates to verified extensions are usually faster. +A: Typically 3-7 business days. Updates to existing extensions are usually faster. ### Q: What if my extension is rejected? @@ -483,11 +287,11 @@ A: You'll receive feedback on what needs to be fixed. Make the changes and resub ### Q: Can I update my extension anytime? -A: Yes, submit a PR to update the catalog with your new version. Verified status may be re-evaluated for major changes. +A: Yes, file a new [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) issue with the updated version and download URL. Mention that it is an update to an existing entry. ### Q: Do I need to be verified to be in the catalog? -A: No, unverified extensions are still searchable. Verification just adds trust and visibility. +A: No. All community extensions are listed in the catalog once their submission is reviewed and accepted. ### Q: Can extensions have paid features? @@ -536,7 +340,7 @@ A: Extensions should be free and open-source. Commercial support/services are al "hooks": "integer (optional)" }, "tags": ["array of strings (2-10 tags)"], - "verified": "boolean (default: false)", + "verified": "boolean (default: false, set by maintainers)", "downloads": "integer (auto-updated)", "stars": "integer (auto-updated)", "created_at": "string (ISO 8601 datetime)", diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 595985d955..c3391dbc75 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -153,7 +153,7 @@ This will: 2. Validate the manifest 3. Check compatibility with your spec-kit version 4. Install to `.specify/extensions/jira/` -5. Register commands with your AI agent +5. Register commands with your coding agent 6. Create config template ### Install from URL @@ -189,7 +189,7 @@ Provided commands: ### 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. +If your project uses a skills-based integration (e.g., `--integration claude`, `--integration codex`) or was initialized with `--integration-options="--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! @@ -208,7 +208,7 @@ When an extension is removed, its corresponding skills are also cleaned up autom ### Using Extension Commands -Extensions add commands that appear in your AI agent (Claude Code): +Extensions add commands that appear in your coding agent (Claude Code): ```text # In Claude Code @@ -423,7 +423,7 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`), | Variable | Description | Default | |----------|-------------|---------| | `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 | +| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated requests to GitHub-hosted URLs (`raw.githubusercontent.com`, `github.com`, `api.github.com`, `codeload.github.com`). Required when your catalog JSON or extension ZIPs are hosted in a private GitHub repository. | None | #### Example: Using a custom catalog for testing @@ -435,6 +435,21 @@ export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json" export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json" ``` +#### Example: Using a private GitHub-hosted catalog + +```bash +# Authenticate with a token (gh CLI, PAT, or GITHUB_TOKEN in CI) +export GITHUB_TOKEN=$(gh auth token) + +# Search a private catalog added via `specify extension catalog add` +specify extension search jira + +# Install from a private catalog +specify extension add jira-sync +``` + +The token is attached automatically to requests targeting GitHub domains. Non-GitHub catalog URLs are always fetched without credentials. + --- ## Extension Catalogs @@ -780,12 +795,12 @@ specify extension add --dev /path/to/extension ### Command Not Available -**Issue**: Extension command not appearing in AI agent +**Issue**: Extension command not appearing in coding agent **Solutions**: 1. Check extension is enabled: `specify extension list` -2. Restart AI agent (Claude Code) +2. Restart coding agent (Claude Code) 3. Check command file exists: ```bash @@ -819,8 +834,8 @@ specify extension add --dev /path/to/extension **Solutions**: 1. Check MCP server is installed -2. Check AI agent MCP configuration -3. Restart AI agent +2. Check coding agent MCP configuration +3. Restart coding agent 4. Check extension requirements: `specify extension info jira` ### Permission Denied diff --git a/extensions/README.md b/extensions/README.md index f535ba539a..4dc9e64f5c 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -25,13 +25,13 @@ specify extension search # Now uses your organization's catalog instead of the ### 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. +> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — 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 +- **Submission**: Open to community contributions via [issue template](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) **How It Works:** @@ -72,7 +72,7 @@ specify extension add --from https://github.com/org/spec-kit-ex ## 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. +> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — 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/).** @@ -89,10 +89,8 @@ 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 +3. **File an issue** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template with all required metadata +4. **Wait for review** — a maintainer will review the submission, update the catalog, and close the issue See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed step-by-step instructions. diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index da4fc75da3..b9d72ce6e4 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-06T06:30:00Z", + "updated_at": "2026-05-07T15:37:14Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -36,6 +36,172 @@ "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" + }, + "agent-orchestrator": { + "name": "Intelligent Agent Orchestrator", + "id": "agent-orchestrator", + "description": "Cross-catalog agent discovery and intelligent prompt-to-command routing", + "author": "pragya247", + "version": "0.1.0", + "download_url": "https://github.com/pragya247/spec-kit-orchestrator/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/pragya247/spec-kit-orchestrator", + "homepage": "https://github.com/pragya247/spec-kit-orchestrator", + "documentation": "https://github.com/pragya247/spec-kit-orchestrator/blob/main/README.md", + "changelog": "https://github.com/pragya247/spec-kit-orchestrator/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.1" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "orchestrator", + "routing", + "discovery", + "agent", + "ai" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-04T00:00:00Z", + "updated_at": "2026-05-04T00:00:00Z" + }, + "api-evolve": { + "name": "API Evolve", + "id": "api-evolve", + "description": "Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-api-evolve", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-api-evolve", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 12, + "hooks": 5 + }, + "tags": [ + "api", + "contracts", + "versioning", + "openapi", + "graphql", + "grpc", + "deprecation", + "breaking-changes", + "semver", + "governance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-07T00:00:00Z", + "updated_at": "2026-05-07T00: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" + }, + "architecture-guard": { + "name": "Architecture Guard", + "id": "architecture-guard", + "description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.", + "author": "DyanGalih", + "version": "1.6.7", + "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.6.7.zip", + "repository": "https://github.com/DyanGalih/spec-kit-architecture-guard", + "homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard", + "documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md", + "changelog": "https://github.com/DyanGalih/spec-kit-architecture-guard/releases", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 6, + "hooks": 0 + }, + "tags": [ + "architecture", + "governance", + "drift-detection", + "refactor", + "monolithic", + "microservices" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-05T07:26:00Z", + "updated_at": "2026-05-06T22:28:55Z" + }, "archive": { "name": "Archive Extension", "id": "archive", @@ -106,6 +272,134 @@ "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", @@ -141,6 +435,71 @@ "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", @@ -290,6 +649,70 @@ "created_at": "2026-03-29T00:00:00Z", "updated_at": "2026-03-29T00:00:00Z" }, + "cost": { + "name": "Cost Tracker", + "id": "cost", + "description": "Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-cost/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-cost", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-cost", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-cost/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-cost/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "commands": 5, + "hooks": 0 + }, + "tags": [ + "cost", + "budget", + "tokens", + "visibility", + "finance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-03T00:00:00Z", + "updated_at": "2026-05-05T00: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", @@ -368,18 +791,18 @@ "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", + "version": "1.1.0", + "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.1.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" + "speckit_version": ">=0.8.0" }, "provides": { - "commands": 4, + "commands": 5, "hooks": 0 }, "tags": [ @@ -392,7 +815,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-18T00:00:00Z", - "updated_at": "2026-03-18T00:00:00Z" + "updated_at": "2026-04-23T00:00:00Z" }, "fix-findings": { "name": "Fix Findings", @@ -462,8 +885,8 @@ "id": "fleet", "description": "Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases.", "author": "sharathsatish", - "version": "1.0.0", - "download_url": "https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.0.0.zip", + "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", @@ -486,49 +909,159 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-06T00:00:00Z", - "updated_at": "2026-03-06T00:00:00Z" + "updated_at": "2026-03-31T00: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", + "fx-to-dotnet": { + "name": ".NET Framework to Modern .NET Migration", + "id": "fx-to-dotnet", + "description": "Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration.", + "author": "RogerBestMsft", + "version": "0.8.0", + "download_url": "https://github.com/RogerBestMsft/spec-kit-FxToNet/releases/download/v0.8.0/fx-to-dotnet.zip", + "repository": "https://github.com/RogerBestMsft/spec-kit-FxToNet", + "homepage": "https://github.com/RogerBestMsft/spec-kit-FxToNet", + "documentation": "https://github.com/RogerBestMsft/spec-kit-FxToNet/blob/main/README.md", "license": "MIT", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "Microsoft.GitHubCopilot.Modernization.Mcp", + "required": true + } + ] }, "provides": { - "commands": 2, - "hooks": 0 + "commands": 12, + "hooks": 5 }, "tags": [ - "iteration", - "change-management", - "spec-maintenance" + "dotnet", + "migration", + "modernization", + "framework", + "aspnet", + "shared-artifact" ], "verified": false, "downloads": 0, "stars": 0, - "created_at": "2026-03-17T00:00:00Z", - "updated_at": "2026-03-17T00:00:00Z" + "created_at": "2026-05-06T00:00:00Z", + "updated_at": "2026-05-06T00: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", + "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" @@ -580,6 +1113,44 @@ "created_at": "2026-03-17T00:00:00Z", "updated_at": "2026-03-17T00:00:00Z" }, + "m365": { + "name": "Microsoft 365 Integration", + "id": "m365", + "description": "Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation.", + "author": "BenBtg", + "version": "1.0.0", + "download_url": "https://github.com/BenBtg/spec-kit-m365/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/BenBtg/spec-kit-m365", + "homepage": "https://github.com/BenBtg/spec-kit-m365", + "documentation": "https://github.com/BenBtg/spec-kit-m365/blob/main/README.md", + "changelog": "https://github.com/BenBtg/spec-kit-m365/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "m365", + "required": true + } + ] + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "microsoft-365", + "teams", + "transcripts", + "collaboration", + "summarization" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-28T00:00:00Z", + "updated_at": "2026-04-28T00:00:00Z" + }, "maqa": { "name": "MAQA — Multi-Agent & Quality Assurance", "id": "maqa", @@ -806,6 +1377,191 @@ "created_at": "2026-03-26T00:00:00Z", "updated_at": "2026-03-26T00:00:00Z" }, + "markitdown": { + "name": "MarkItDown Document Converter", + "id": "markitdown", + "description": "Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material in Spec Kit workflows.", + "author": "BenBtg", + "version": "1.0.0", + "download_url": "https://github.com/BenBtg/spec-kit-markitdown/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/BenBtg/spec-kit-markitdown", + "homepage": "https://github.com/BenBtg/spec-kit-markitdown", + "documentation": "https://github.com/BenBtg/spec-kit-markitdown/blob/main/README.md", + "changelog": "https://github.com/BenBtg/spec-kit-markitdown/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "markitdown", + "version": ">=0.1.0", + "required": true + } + ] + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "markdown", + "pdf", + "document-conversion", + "reference-material", + "extraction" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-28T00:00:00Z", + "updated_at": "2026-04-28T00: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": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context", + "author": "DyanGalih", + "version": "0.7.9", + "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.9.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/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 6, + "hooks": 0 + }, + "tags": [ + "memory", + "workflow", + "docs", + "copilot", + "markdown", + "ai-context" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-23T00:00:00Z", + "updated_at": "2026-05-06T22:28:55Z" + }, + "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" + }, + "multi-model-review": { + "name": "Multi-Model Review", + "id": "multi-model-review", + "description": "Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review.", + "author": "formin", + "version": "0.1.0", + "download_url": "https://github.com/formin/multi-model-review/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/formin/multi-model-review", + "homepage": "https://github.com/formin/multi-model-review", + "documentation": "https://github.com/formin/multi-model-review/blob/main/README.md", + "changelog": "https://github.com/formin/multi-model-review/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0", + "tools": [ + { + "name": "git", + "required": true + }, + { + "name": "codex", + "required": false + }, + { + "name": "gemini", + "required": false + }, + { + "name": "claude", + "required": false + } + ] + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "review", + "workflow", + "multi-model", + "spec-driven-development", + "code" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-04T02:51:52Z", + "updated_at": "2026-05-04T02:51:52Z" + }, "onboard": { "name": "Onboard", "id": "onboard", @@ -871,6 +1627,38 @@ "created_at": "2026-04-03T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z" }, + "orchestrator": { + "name": "Spec Orchestrator", + "id": "orchestrator", + "description": "Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-orchestrator", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-orchestrator", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/releases", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "orchestration", + "multi-feature", + "coordination", + "workflow", + "parallel" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-24T14:00:00Z", + "updated_at": "2026-04-24T14:00:00Z" + }, "plan-review-gate": { "name": "Plan Review Gate", "id": "plan-review-gate", @@ -902,6 +1690,38 @@ "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", @@ -936,10 +1756,10 @@ "product-forge": { "name": "Product Forge", "id": "product-forge", - "description": "Full product lifecycle: research → product spec → SpecKit → implement → verify → test", + "description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model", "author": "VaiYav", - "version": "1.1.1", - "download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.1.1.zip", + "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", @@ -949,21 +1769,21 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 10, + "commands": 29, "hooks": 0 }, "tags": [ "process", - "research", - "product-spec", "lifecycle", - "testing" + "monorepo", + "v-model", + "portfolio" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-03-28T00:00:00Z", - "updated_at": "2026-03-28T00:00:00Z" + "updated_at": "2026-04-24T15:52:00Z" }, "qa": { "name": "QA Testing Extension", @@ -1000,12 +1820,12 @@ "id": "ralph", "description": "Autonomous implementation loop using AI agent CLI.", "author": "Rubiss", - "version": "1.0.0", - "download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.0.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", + "version": "1.0.2", + "download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.0.2.zip", + "repository": "https://github.com/Rubiss-Projects/spec-kit-ralph", + "homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph", + "documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md", + "changelog": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/CHANGELOG.md", "license": "MIT", "requires": { "speckit_version": ">=0.1.0", @@ -1034,7 +1854,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-09T00:00:00Z", - "updated_at": "2026-03-09T00:00:00Z" + "updated_at": "2026-05-04T17:02:08Z" }, "reconcile": { "name": "Reconcile Extension", @@ -1067,25 +1887,89 @@ "created_at": "2026-03-14T00:00:00Z", "updated_at": "2026-03-14T00: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", + "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", - "tools": [ - { - "name": "no need", - "version": ">=1.0.0", - "required": false + "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 } ] }, @@ -1171,8 +2055,8 @@ "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.0", - "download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.0.zip", + "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", @@ -1198,15 +2082,80 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-06T00:00:00Z", - "updated_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", + "description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews", "author": "DyanGalih", - "version": "1.1.1", - "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.1.1.zip", + "version": "1.4.5", + "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.5.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", @@ -1216,7 +2165,7 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 3, + "commands": 7, "hooks": 0 }, "tags": [ @@ -1230,7 +2179,51 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-03T03:24:03Z", - "updated_at": "2026-04-03T04:15:00Z" + "updated_at": "2026-05-06T22:28:55Z" + }, + "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", @@ -1262,6 +2255,101 @@ "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" + }, + "spec2cloud": { + "name": "Spec2Cloud", + "id": "spec2cloud", + "description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.", + "author": "Azure Samples", + "version": "1.1.0", + "download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/extension.zip", + "repository": "https://github.com/Azure-Samples/Spec2Cloud", + "homepage": "https://aka.ms/spec2cloud", + "documentation": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/README.md", + "changelog": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "spec2cloud", + "azure", + "cloud", + "deploy", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-30T00:00:00Z", + "updated_at": "2026-04-30T00:00:00Z" + }, "speckit-utils": { "name": "SDD Utilities", "id": "speckit-utils", @@ -1294,6 +2382,78 @@ "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" + }, + "squad": { + "name": "Squad Bridge", + "id": "squad", + "description": "Bootstrap and synchronize a Squad agent team from your Spec Kit spec and tasks.", + "author": "jwill824", + "version": "1.1.0", + "download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.1.0.zip", + "repository": "https://github.com/jwill824/spec-kit-squad", + "homepage": "https://github.com/jwill824/spec-kit-squad", + "documentation": "https://github.com/jwill824/spec-kit-squad/blob/main/README.md", + "changelog": "https://github.com/jwill824/spec-kit-squad/blob/main/docs/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "@bradygaster/squad-cli", + "version": ">=0.1.0", + "required": true + } + ] + }, + "provides": { + "commands": 4, + "hooks": 2 + }, + "tags": [ + "multi-agent", + "agents", + "orchestration", + "process", + "integration" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-29T00:00:00Z", + "updated_at": "2026-04-29T00:00:00Z" + }, "staff-review": { "name": "Staff Review Extension", "id": "staff-review", @@ -1356,13 +2516,43 @@ "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.0.0", - "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.0.0/superpowers-bridge.zip", + "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", @@ -1397,7 +2587,40 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-30T00:00:00Z", - "updated_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", @@ -1431,13 +2654,108 @@ "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" + }, + "threatmodel": { + "name": "OWASP LLM Threat Model", + "id": "threatmodel", + "description": "OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts", + "author": "NaviaSamal", + "version": "1.0.0", + "download_url": "https://github.com/NaviaSamal/spec-kit-threatmodel/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/NaviaSamal/spec-kit-threatmodel", + "homepage": "https://github.com/NaviaSamal/spec-kit-threatmodel", + "documentation": "https://github.com/NaviaSamal/spec-kit-threatmodel/blob/main/README.md", + "changelog": "https://github.com/NaviaSamal/spec-kit-threatmodel/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "security", + "owasp", + "threat-model", + "llm", + "analysis" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-25T00:00:00Z", + "updated_at": "2026-04-25T00:00:00Z" + }, + "token-analyzer": { + "name": "Token Consumption Analyzer", + "id": "token-analyzer", + "description": "Captures, analyzes, and compares token consumption across SDD workflows", + "author": "Chris Roberts | coderandhiker", + "version": "0.1.0", + "download_url": "https://github.com/coderandhiker/spec-kit-token-analyzer/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/coderandhiker/spec-kit-token-analyzer", + "homepage": "https://github.com/coderandhiker/spec-kit-token-analyzer", + "documentation": "https://github.com/coderandhiker/spec-kit-token-analyzer/blob/main/README.md", + "changelog": "https://github.com/coderandhiker/spec-kit-token-analyzer/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 3, + "hooks": 4 + }, + "tags": [ + "tokens", + "measurement", + "optimization", + "analysis" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-01T00:00:00Z", + "updated_at": "2026-05-01T00: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", + "version": "0.6.0", + "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.6.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", @@ -1459,17 +2777,17 @@ ], "verified": false, "downloads": 0, - "stars": 0, + "stars": 21, "created_at": "2026-02-20T00:00:00Z", - "updated_at": "2026-04-06T00:00:00Z" + "updated_at": "2026-04-25T00: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.0", - "download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.0.zip", + "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", @@ -1493,7 +2811,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-03T00:00:00Z", - "updated_at": "2026-03-03T00:00:00Z" + "updated_at": "2026-04-09T00:00:00Z" }, "verify-tasks": { "name": "Verify Tasks Extension", @@ -1525,6 +2843,208 @@ "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" + }, + "workiq": { + "name": "Work IQ", + "id": "workiq", + "description": "Integrate Microsoft 365 organizational knowledge into spec-driven development workflows", + "author": "sakitA", + "version": "1.0.0", + "download_url": "https://github.com/sakitA/spec-kit-workiq/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/sakitA/spec-kit-workiq", + "homepage": "https://github.com/sakitA/spec-kit-workiq", + "documentation": "https://github.com/sakitA/spec-kit-workiq/blob/main/README.md", + "changelog": "https://github.com/sakitA/spec-kit-workiq/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "workiq", + "version": ">=1.0.0", + "required": true + }, + { + "name": "node", + "version": ">=18.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 4, + "hooks": 2 + }, + "tags": [ + "microsoft-365", + "work-iq", + "context", + "integration", + "productivity" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-29T00:00:00Z", + "updated_at": "2026-04-29T00: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.json b/extensions/catalog.json index a039883ba2..de9372e2bc 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-06T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", "extensions": { "git": { @@ -10,27 +10,13 @@ "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", - "download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip", + "bundled": true, "tags": [ "git", "branching", "workflow", "core" ] - }, - "selftest": { - "name": "Spec Kit Self-Test Utility", - "id": "selftest", - "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", - "download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip", - "tags": [ - "testing", - "core", - "utility" - ] } } } \ No newline at end of file diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 13a7d0784d..5bed9e5e57 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -4,7 +4,7 @@ description: "Create a feature branch with sequential or timestamp numbering" # Create Feature Branch -Create a new feature branch for the given specification. +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_COMMAND_SPECIFY__` workflow. ## User Input @@ -14,10 +14,17 @@ $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 (spec directory will still be created) +- If Git is not available, warn the user and skip branch creation ## Branch Numbering Mode @@ -45,22 +52,16 @@ Run the appropriate script based on your platform: - 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 SPEC_FILE paths - -If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to: -- **Bash**: `scripts/bash/create-new-feature.sh` -- **PowerShell**: `scripts/powershell/create-new-feature.ps1` +- 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: -- The script will still create the spec directory under `specs/` -- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation` -- The workflow continues normally without branch creation +- 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 created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) -- `SPEC_FILE`: Path to the created spec file +- `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/scripts/bash/auto-commit.sh b/extensions/git/scripts/bash/auto-commit.sh index 49c32fe634..f0b423187b 100755 --- a/extensions/git/scripts/bash/auto-commit.sh +++ b/extensions/git/scripts/bash/auto-commit.sh @@ -137,4 +137,4 @@ fi _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 "✓ Changes committed ${_phase} ${_command_name}" >&2 +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 index dfae29df73..f7aa31610e 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -64,17 +64,21 @@ while [ $i -le $# ]; do echo "" echo "Options:" echo " --json Output in JSON format" - echo " --dry-run Compute branch name and paths without creating branches, directories, or files" + 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 ;; *) @@ -91,7 +95,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then fi # Trim whitespace and validate description is not empty -FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +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 @@ -258,9 +262,6 @@ fi cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" -if [ "$DRY_RUN" != true ]; then - mkdir -p "$SPECS_DIR" -fi # Function to generate branch name with stop word filtering generate_branch_name() { @@ -301,45 +302,67 @@ generate_branch_name() { fi } -# Generate branch name -if [ -n "$SHORT_NAME" ]; then - BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +# 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 - BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") -fi + # 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 + # 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)) + # 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 - fi - FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + 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 -if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then +_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)) @@ -354,9 +377,6 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi -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="" @@ -366,8 +386,11 @@ if [ "$DRY_RUN" != true ]; then if [ "$ALLOW_EXISTING" = true ]; then if [ "$current_branch" = "$BRANCH_NAME" ]; then : - elif ! git checkout "$BRANCH_NAME" 2>/dev/null; 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 @@ -391,22 +414,6 @@ if [ "$DRY_RUN" != true ]; then >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" fi - mkdir -p "$FEATURE_DIR" - - if [ ! -f "$SPEC_FILE" ]; then - if type resolve_template >/dev/null 2>&1; then - TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true - else - TEMPLATE="" - fi - 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 - printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 fi @@ -415,35 +422,30 @@ if $JSON_MODE; 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}' + '{BRANCH_NAME:$branch_name,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}' + '{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_spec=$(json_escape "$SPEC_FILE") _je_num=$(json_escape "$FEATURE_NUM") else _je_branch="$BRANCH_NAME" - _je_spec="$SPEC_FILE" _je_num="$FEATURE_NUM" fi if [ "$DRY_RUN" = true ]; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_spec" "$_je_num" + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num" else - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_spec" "$_je_num" + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num" fi fi else echo "BRANCH_NAME: $BRANCH_NAME" - echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" if [ "$DRY_RUN" != true ]; then printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index 882a385e28..b78356d1c6 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -11,10 +11,22 @@ has_git() { 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 branch="$1" + local raw="$1" local has_git_repo="$2" # For non-git repos, we can't enforce branch naming but still provide output @@ -23,19 +35,20 @@ check_feature_branch() { return 0 fi - # Reject malformed timestamps (7-digit date, 8-digit date without trailing slug, or 7-digit with slug) - if [[ "$branch" =~ ^[0-9]{7}-[0-9]{6} ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 - return 1 - fi + local branch + branch=$(spec_kit_effective_branch_name "$raw") - # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) - if [[ "$branch" =~ ^[0-9]{3,}- ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then - return 0 + # 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 - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 - return 1 + return 0 } diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 index e9777ff9be..4a8b0e00cd 100644 --- a/extensions/git/scripts/powershell/auto-commit.ps1 +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -36,10 +36,17 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) { 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 - if ($LASTEXITCODE -ne 0) { throw "not a repo" } -} catch { + $isRepo = $LASTEXITCODE -eq 0 +} finally { + $ErrorActionPreference = $savedEAP +} +if (-not $isRepo) { Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit" exit 0 } @@ -117,9 +124,16 @@ if (-not $enabled) { } # Check if there are changes to commit -$diffHead = git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE -$diffCached = git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE -$untracked = git ls-files --others --exclude-standard 2>$null +# 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 @@ -136,6 +150,10 @@ if (-not $commitMsg) { } # 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" } @@ -144,6 +162,8 @@ try { } catch { Write-Warning "[specify] Error: $_" exit 1 +} finally { + $ErrorActionPreference = $savedEAP } -Write-Host "✓ Changes committed $phase $commandName" +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 index 75a4e69814..b579f05160 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -23,12 +23,16 @@ if ($Help) { 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 " -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 } @@ -203,7 +207,9 @@ if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { # Check if git is available if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { - $hasGit = Test-HasGit -RepoRoot $repoRoot + # 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 @@ -216,9 +222,6 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' -if (-not $DryRun) { - New-Item -ItemType Directory -Path $specsDir -Force | Out-Null -} function Get-BranchName { param([string]$Description) @@ -255,35 +258,54 @@ function Get-BranchName { } } -if ($ShortName) { - $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName +# 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 { - $branchSuffix = Get-BranchName -Description $featureDesc -} + 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 -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 + 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" + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" + } } $maxBranchLength = 244 @@ -302,9 +324,6 @@ if ($branchName.Length -gt $maxBranchLength) { Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } -$featureDir = Join-Path $specsDir $branchName -$specFile = Join-Path $featureDir 'spec.md' - if (-not $DryRun) { if ($hasGit) { $branchCreated = $false @@ -327,9 +346,13 @@ if (-not $DryRun) { if ($currentBranch -eq $branchName) { # Already on the target branch } else { - git checkout -q $branchName 2>$null | Out-Null + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { - Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + 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 } } @@ -357,28 +380,12 @@ if (-not $DryRun) { } } - New-Item -ItemType Directory -Path $featureDir -Force | Out-Null - - if (-not (Test-Path -PathType Leaf $specFile)) { - if (Get-Command Resolve-Template -ErrorAction SilentlyContinue) { - $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot - } else { - $template = $null - } - if ($template -and (Test-Path $template)) { - Copy-Item $template $specFile -Force - } else { - New-Item -ItemType File -Path $specFile -Force | Out-Null - } - } - $env:SPECIFY_FEATURE = $branchName } if ($Json) { $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName - SPEC_FILE = $specFile FEATURE_NUM = $featureNum HAS_GIT = $hasGit } @@ -388,7 +395,6 @@ if ($Json) { $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" if (-not $DryRun) { diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 index 8a9c4fd6cc..82210000b6 100644 --- a/extensions/git/scripts/powershell/git-common.ps1 +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -15,6 +15,14 @@ function Test-HasGit { } } +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + function Test-FeatureBranch { param( [string]$Branch, @@ -27,24 +35,17 @@ function Test-FeatureBranch { return $true } - # Reject malformed timestamps (7-digit date or no trailing slug) - $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or - ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') - if ($hasMalformedTimestamp) { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" - return $false - } + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw - # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) + # 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) - $isTimestamp = $Branch -match '^\d{8}-\d{6}-' - - if ($isSequential -or $isTimestamp) { - return $true + 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 } - - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" - return $false + return $true } 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..16e321cf58 --- /dev/null +++ b/integrations/catalog.json @@ -0,0 +1,277 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-29T00: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"] + }, + "devin": { + "id": "devin", + "name": "Devin for Terminal", + "version": "1.0.0", + "description": "Devin for Terminal 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"] + }, + "lingma": { + "id": "lingma", + "name": "Lingma", + "version": "1.0.0", + "description": "Lingma IDE skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "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-April.md b/newsletters/2026-April.md new file mode 100644 index 0000000000..913dedaf23 --- /dev/null +++ b/newsletters/2026-April.md @@ -0,0 +1,147 @@ +# Spec Kit - April 2026 Newsletter + +This edition covers Spec Kit activity in April 2026. Seventeen releases shipped (v0.4.4 through v0.8.3), delivering a full integration plugin architecture, a workflow engine, preset composition strategies, an integration catalog, and comprehensive documentation. The community extension catalog tripled from 26 to 83 entries, community presets grew from 2 to 12, and Spec Kit appeared on the Thoughtworks Technology Radar. A summary is in the table below, followed by details. + +| **Spec Kit Core (Apr 2026)** | **Community & Content** | **SDD Ecosystem & Next** | +| --- | --- | --- | +| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. 12 substantive external articles published. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. | + +*** + +> **Important:** April's release pace outran external coverage. Most analyses published during the month (Rickard on April 1, Thoughtworks Radar on April 15, XB Software on April 17, Torber on April 23) were evaluating versions that predated the workflow engine (v0.7.0), integration catalog (v0.7.2), preset composition (v0.8.0), and catalog discovery CLI (v0.8.3). The ceremony and flexibility concerns they raised are precisely what these features address — the lean preset, pluggable workflows, composable presets, and community extensions like Conduct, MAQA, and Fleet Orchestrator already deliver alternative workflows beyond the default SDD process. We look forward to seeing how upcoming reviews account for these capabilities. + +## Spec Kit Project Updates + +### Releases Overview + +**v0.4.4** (April 1) delivered the first stage of the **integration plugin architecture** — base classes, a manifest system, and a registry that replaced the hard-coded agent scaffolding. It also added the Product Forge, Superpowers Bridge, MAQA suite (7 extensions), Spec Kit Onboard, and Plan Review Gate to the community catalog, fixed Claude Code CLI detection for npm-local installs, and added `--allow-existing-branch` to `create-new-feature`. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.4.4) + +**v0.4.5** (April 2) completed the integration migration in five stages: standard markdown integrations for 19 agents, TOML integrations (Gemini, Tabnine), skills and generic integrations, and removal of the legacy scaffold path. It also installed Claude Code as native skills, added a `--dry-run` flag for `create-new-feature`, support for 4+ digit feature branch numbers, the Fix Findings extension, and five lifecycle extensions to the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.4.5) + +**v0.5.0** (April 2) was a significant packaging change: **template zip bundles were removed from releases**, with the CLI itself now handling all scaffolding. This ensured CLI and templates stay in sync. It also introduced `DEVELOPMENT.md` for contributor onboarding. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.5.0) + +**v0.5.1** (April 8) was a large patch release. It added the **bundled Git extension** (stages 1 and 2) with hooks on all core commands and `GIT_BRANCH_NAME` override support, **Forgecode** agent support, and the `specify integration` subcommand for post-init integration management. Argument hints were added to Claude Code commands. Numerous community extensions joined the catalog (Confluence, Canon, Spec Diagram, Branch Convention, Spec Refine, FixIt, Optimize, Security Review) along with presets (explicit-task-dependencies, toc-navigation, VS Code Ask Questions). Bug fixes included pinning typer≥0.24.0/click≥8.2.1 to fix an import crash, BSD-portable sed escaping, Trae agent fix, TOML frontmatter stripping, and preventing ambiguous TOML closing quotes. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.5.1) + +**v0.6.0** (April 9) rewrote **AGENTS.md for the new integration architecture**, added the SpecKit Companion to Community Friends, and brought Bugfix Workflow, Worktree Isolation, and MemoryLint to the community catalog. A new multi-repo-branching preset arrived. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.0) + +**v0.6.1** (April 10) added the **bundled lean preset** with a minimal workflow command set — a lighter-weight alternative to the full SDD ceremony. It also migrated **Cursor** from `.cursor/commands` to `.cursor/skills` and added Brownfield Bootstrap, CI Guard, SpecTest, PR Bridge, TinySpec, and Status Report to the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.1) + +**v0.6.2** (April 13) added **Goose AI agent** support (YAML-based recipe format), the GitHub Issues Integration extension, and the What-if Analysis extension. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.2) + +**v0.7.0** (April 14) delivered the **workflow engine with catalog system**, enabling pluggable, multi-step workflow definitions. It added SFSpeckit (Salesforce SDD), the Worktrees extension, optional single-segment branch prefix for gitflow compatibility, and the claude-ask-questions and fiction-book-writing presets. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.0) + +**v0.7.1** (April 15) deprecated the `--ai` flag in favor of `--integration` on `specify init`, added Windows to the CI test matrix, fixed Claude skill chaining for hook execution, merged TESTING.md into CONTRIBUTING.md, and added the Agent Assign and Architect Preview extensions. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.1) + +**v0.7.2** (April 16) delivered the **integration catalog** for discovery, versioning, and community distribution of agent integrations. It also produced a major **documentation overhaul**: reference pages for core commands, extensions, presets, workflows, and integrations were added to `docs/reference/`, and the README CLI section was simplified. The Issues extension and Catalog CI extension joined the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.2) + +**v0.7.3** (April 17) replaced shell-based context updates with a **marker-based upsert** mechanism, eliminating accidental context file bloat. It added a **Community Friends page** to the docs site, the Spec Scope and Blueprint extensions, and a Claude Code/Copilot CLI plugin marketplace reference in the README. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.3) + +**v0.7.4** (April 21) added **CITATION.cff and .zenodo.json** for academic citation support. It introduced Ripple (side-effect detection), Spec Validate, Version Guard, Spec Reference Loader, and Memory Loader extensions. A fix stripped UTF-8 BOM from agent context files, and the Antigravity (agy) agent layout was migrated to `.agents/` with `--skills` deprecated. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.4) + +**v0.7.5** (April 22) added `specify self check` and `self upgrade` stubs, the **preset wrap strategy** (completing the composition trifecta alongside prepend and append), the Red Team adversarial review extension, the Wireframe extension, and a **directory traversal security fix** in command write paths. Skill placeholder resolution was expanded to all SKILL.md agents. Community content (walkthroughs and presets) was moved from the README to the docs site. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.5) + +**v0.8.0** (April 23) delivered **preset composition strategies** (prepend, append, wrap) for templates, commands, and scripts — enabling presets to layer content around existing artifacts. It also added Copilot `--integration-options="--skills"` for skills-based scaffolding, `pipx` as an alternative installation method, and the Memory MD extension. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.0) + +**v0.8.1** (April 24) fixed `/speckit.plan` on custom git branches via `.specify/feature.json`, migrated the **Mistral Vibe** integration to SkillsIntegration, added the **Screenwriting** and **Jira** presets, and resolved command reference formats per integration type (dot vs. hyphen notation). [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.1) + +**v0.8.2** (April 28) introduced **GITHUB_TOKEN/GH_TOKEN authentication** for private catalog and extension downloads, deprecated the `--no-git` flag (removal gated at v0.10.0), replaced all deprecated `--ai` references with `--integration` in documentation, and added MarkItDown Document Converter, Microsoft 365 Integration, Spec Orchestrator, and the Fiction Book Writing v1.7 preset with RAG (Chroma DB) offline semantic search. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.2) + +**v0.8.3** (April 29) closed the month with **catalog discovery CLI commands** (search, info, catalog list/add/remove), support for **Devin for Terminal** as a skills-based integration, a fix for the opencode command dispatch, and the OWASP LLM Threat Model, iSAQB Architecture Governance, and Work IQ extensions. A fix was also added to the upgrade hint to prevent users from accidentally installing a PyPI squat package. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.3) + +### Architecture & Infrastructure Highlights + +The most significant architectural change in April was the **integration plugin architecture** (v0.4.4–v0.4.5), which replaced hard-coded agent scaffolding with a registry of self-describing integration classes. Each agent is now a self-contained subpackage under `src/specify_cli/integrations//` with base classes for Markdown, TOML, YAML, and Skills formats. This six-stage migration touched all 28 supported agents and laid the groundwork for the integration catalog (v0.7.2) and community-distributed integrations. + +The **workflow engine** (v0.7.0) introduced a catalog-based system for pluggable, multi-step workflow definitions — moving beyond the fixed seven-step SDD sequence. + +**Preset composition strategies** (v0.7.5/v0.8.0) completed the preset system with prepend, append, and wrap modes. Presets can now layer content around existing templates, commands, and scripts rather than only replacing them. + +The **marker-based context upsert** (v0.7.3) replaced fragile shell-based sed operations for updating agent context files, eliminating a class of bugs around context bloat and encoding issues. + +**Template zip bundles were removed** (v0.5.0), coupling the CLI and templates into a single distributable artifact. + +### Bug Fixes and Security + +The most critical fix was **blocking directory traversal in command write paths** (#2229, v0.7.5), which prevented a potential path traversal vulnerability in the CommandRegistrar. Other security-adjacent fixes included hardening against a **PyPI squat package** in upgrade hints (v0.8.3) and adding **GITHUB_TOKEN authentication** for private catalog downloads (v0.8.2). + +Notable bug fixes: typer/click import crash (v0.5.1), BSD-portable sed escaping (v0.5.1), UTF-8 BOM stripping from context files (v0.7.4), CRLF warning suppression in PowerShell auto-commit (v0.7.3), Claude skill chaining for hooks (v0.7.1), TOML ambiguous closing quotes (v0.5.1), and custom branch support for `/speckit.plan` (v0.8.1). [\[github.com\]](https://github.com/github/spec-kit/releases) + +### The Extension & Preset Ecosystem + +The community extension catalog **tripled** during April, growing from 26 to **83 entries**. 59 new extensions were added and 2 were removed (Cognitive Squad and Understanding, whose repositories were no longer available). Community presets grew from 2 to **12 entries**, with 10 new presets added. + +Notable new extensions by category: + +- **Project management**: GitHub Issues Integration (Fatima367, aaronrsun), Spec Orchestrator (Quratulain-bilal), Agent Assign (xuyang), Status Report (Open-Agent-Tools) +- **Quality & security**: Red Team adversarial review (Ash Brener), Security Review (DyanGalih), Ripple side-effect detection (chordpli), Spec Validate (Ahmed Eltayeb), CI Guard (Quratulain-bilal), OWASP LLM Threat Model (NaviaSamal) +- **Multi-agent & orchestration**: MAQA suite with 7 extensions covering multi-agent QA, Jira, Azure DevOps, GitHub Projects, Linear, and Trello integrations (GenieRobot), Product Forge (VaiYav) +- **Spec lifecycle**: Spec Refine (Quratulain-bilal), Bugfix Workflow (Quratulain-bilal), Fix Findings (Quratulain-bilal), Brownfield Bootstrap (Quratulain-bilal), TinySpec (Quratulain-bilal) +- **Developer experience**: Blueprint code review (chordpli), Confluence (aaronrsun), MarkItDown Document Converter (BenBtg), Microsoft 365 Integration (BenBtg), Memory MD (DyanGalih), Memory Loader (KevinBrown5280), MemoryLint (RbBtSn0w) +- **Domain-specific**: SFSpeckit for Salesforce (Sumanth Yanamala), iSAQB Architecture Governance preset (Thorsten Hindermann), Canon baseline-driven workflows (Maxim Stupakov) +- **Creative**: Fiction Book Writing preset v1.7 with RAG/Chroma DB support (Andreas Daumann), Screenwriting preset (Andreas Daumann) + +Notable contributor **Quratulain-bilal** contributed 15 extensions during the month, spanning spec lifecycle, workflow management, and CI/CD integration. **GenieRobot** contributed the 7-extension MAQA suite. **BenBtg** contributed both MarkItDown and Microsoft 365 integrations. [\[github.com\]](https://github.com/github/spec-kit/releases) + +### Documentation Overhaul + +April saw a comprehensive documentation effort. Reference pages for **core commands, extensions, presets, workflows, and integrations** were created under `docs/reference/`. Community content — **walkthroughs, presets, and a Community Friends page** — was moved from the README to `docs/community/`, reducing README length while improving discoverability. The deprecated `--ai` flag references were replaced with `--integration` across all documentation. TESTING.md was merged into CONTRIBUTING.md, and `DEVELOPMENT.md` was introduced for contributor onboarding. [\[github.com\]](https://github.com/github/spec-kit/releases) + +## Community & Content + +### Thoughtworks Technology Radar + +On **April 15**, the **Thoughtworks Technology Radar Volume 34** placed GitHub Spec Kit in the **"Assess" ring** under Languages & Frameworks. The blip noted that teams report value in brownfield projects, that the constitution captures project scope and architecture, but flagged potential **instruction bloat, context rot, and verbose markdown output** as concerns to watch. This is the first appearance of any SDD-specific tool on the Radar. [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit) + +### Developer Articles and Blog Posts + +April produced 12 substantive external articles (plus one excluded as AI-generated SEO spam). + +**Matt Rickard** published *"The Spec Layer: Why Spec-Driven Development (SDD) Works"* on April 1. His thesis: specs reduce execution freedom for AI agents, functioning as constraint surfaces. He compared Spec Kit, Kiro, OpenSpec, Tessl, Intent, and Symphony, and advocated for **"smaller specs, harder checks, less guessing."** [\[blog.matt-rickard.com\]](https://blog.matt-rickard.com/p/the-spec-layer) + +**Fabián Silva** published *"I Built a Visual Spec-Driven Development Extension for VS Code That Works With Any LLM"* on April 3 on DEV Community. His **Caramelo** VS Code extension adds a visual UI, approval gates, Jira integration, and multi-LLM support on top of Spec Kit's workflow, reading and writing the standard `specs/` directory. [\[dev.to\]](https://dev.to/fabian_silva_/i-built-a-visual-spec-driven-development-extension-for-vs-code-that-works-with-any-llm-36ok) + +**James M** published *"GitHub Spec Kit in 2026: SDD Goes Mainstream"* on April 4, calling the transition "from framework to platform" and highlighting Claude Code native skills, multi-agent support, and the massive ecosystem growth. [\[jamesm.blog\]](https://jamesm.blog/ai/github-spec-kit-2026-update/) + +**Peter Saktor** published a detailed tutorial on DEV Community on April 6: *"GitHub Spec-Kit: From Vibe Coding to Spec-Driven Development,"* walking through a full 7-step SDD workflow refactoring an Azure Container App with 33 tasks across 6 phases. [\[dev.to\]](https://dev.to/petersaktor/github-spec-kit-from-vibe-coding-to-spec-driven-development-1pgd) + +**Codexplorer** published *"Spec Kit: GitHub's Answer to 'The AI Built the Wrong Thing Again'"* on Medium (April 11), framing Spec Kit as flipping the spec-code relationship, with Go code examples covering the seven slash commands. [\[medium.com\]](https://codexplorer.medium.com/spec-kit-githubs-answer-to-the-ai-built-the-wrong-thing-again-22f122f142fb) + +**XB Software** published *"Spec Kit on a Real Project: Implementation Experience in Large Legacy Code"* on April 17 — a field report from applying SDD to legacy systems. A week-long task was completed in half the time. The AI surfaced hidden requirements gaps. They noted API integration weakness, that SDD is overkill for small tasks, and that an experienced reviewer is still essential. [\[xbsoftware.com\]](https://xbsoftware.com/blog/ai-in-legacy-systems-spec-driven-development/) + +**What IT Is** published *"Perspectives in Spec Driven Development"* on April 21, surveying the SDD landscape (Spec Kit, Kiro, Tessl) and calling Spec Kit "a good entry point." [\[theitsolutionist.com\]](https://theitsolutionist.com/2026/04/21/perspectives-in-spec-driven-development/) + +**Will Torber** published *"Spec Kit vs BMAD vs OpenSpec: Choosing an SDD Framework in 2026"* on DEV Community on April 23. He recommended Spec Kit for greenfield but flagged brownfield friction and the branch-per-spec limitation, ultimately **recommending OpenSpec for most teams**. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j) + +**Truong Phung** published *"Spec Kit vs. Superpowers: A Comprehensive Comparison & Practical Guide to Combining Both"* on DEV Community on April 25 — an 11-section comparison proposing a hybrid workflow: "Spec Kit plans WHAT, Superpowers controls HOW," with a step-by-step playbook. [\[dev.to\]](https://dev.to/truongpx396/spec-kit-vs-superpowers-a-comprehensive-comparison-practical-guide-to-combining-both-52jj) + +**Markus Wondrak** published *"Re-evaluating GitHub's Spec Kit: Structured SDLC Automation"* on LinkedIn on April 26, examining Spec Kit as a structured SDLC automation approach requiring human review at phase boundaries. [\[linkedin.com\]](https://www.linkedin.com/pulse/re-evaluating-githubs-spec-kit-structured-sdlc-markus-wondrak-eewqf/) + +**FintechExtra** published a factual release-notes summary of v0.8.2 on April 28, highlighting authenticated catalog downloads, the UTF-8 manifest fix, and the Chroma DB semantic search in the fiction writing preset. [\[fintechextra.com\]](https://www.fintechextra.com/news/github-spec-kit-v082-expands-catalog-support-and-tightens-cli-behavior-331) + +### Community Friends and Tools + +The **SpecKit Companion** VS Code extension was added to the Community Friends section (v0.6.0). A community-maintained plugin for **Claude Code and GitHub Copilot CLI** that installs Spec Kit skills via the plugin marketplace was referenced in the README (v0.7.3). Fabián Silva's **Caramelo** VS Code extension demonstrated a visual UI approach to SDD. [\[github.com\]](https://github.com/github/spec-kit) + +## SDD Ecosystem & Industry Trends + +### The "Spec Layer" Debate + +Matt Rickard's "The Spec Layer" essay established a new framing for SDD: specifications as **constraint surfaces** that reduce execution freedom for AI agents. His comparison of six SDD tools argued for smaller, more focused specs with harder verification checks — a departure from comprehensive specification documents. This framing resonated across the community, with the Thoughtworks Radar entry and multiple comparison articles echoing the tension between spec depth and practical overhead. + +### Competitive Landscape + +**Will Torber's** three-framework comparison (Spec Kit, BMAD, OpenSpec) recommended **OpenSpec for most teams**, citing lower ceremony and better brownfield support. **Truong Phung** proposed combining Spec Kit with **Superpowers** (Jesse Vincent) for a "plan WHAT + control HOW" hybrid. These comparisons reflected a maturing market where practitioners combine tools rather than picking one. + +The **Thoughtworks Radar** placement validated SDD as a category worth tracking but flagged instruction bloat and context rot as open concerns — the same issues the Augment Code comparison raised in March. XB Software's field report confirmed these in practice: SDD adds value for complex legacy work but creates unnecessary overhead for small tasks. + +Spec Kit continued to lead in **GitHub popularity** (92k stars) and **agent breadth** (29 integrations). The market continued to differentiate along several axes: Spec Kit on portability and ecosystem breadth, Intent on living specs and drift detection, BMAD-METHOD on multi-agent orchestration, and OpenSpec on simplicity. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j) [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit) + +## Roadmap + +Areas under discussion or in progress for future development: + +- **Spec lifecycle management** — context rot and spec drift remained the most cited concern across articles (Thoughtworks Radar, XB Software, Will Torber). The marker-based upsert (v0.7.3) addressed context file drift; spec-level drift detection remains an open area. The Reconcile and Archive extensions are community steps toward this. [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit) +- **Workflow customization** — the workflow engine (v0.7.0) and preset composition strategies (v0.8.0) provide the foundation. Community presets for fiction writing, screenwriting, Jira tracking, and architecture governance demonstrate the breadth of possible workflows beyond standard SDD. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **Catalog discovery and distribution** — the integration catalog (v0.7.2) and catalog discovery CLI (v0.8.3) bring `specify` closer to a package-manager experience for extensions, presets, and integrations. Private catalog authentication (v0.8.2) supports enterprise distribution. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **Experience simplification** — the bundled lean preset (v0.6.1), `specify self check` (v0.7.5), and the deprecation of `--ai` in favor of `--integration` (v0.7.1) reflect ongoing work to reduce ceremony and improve the onboarding experience. Multiple external articles (Torber, XB Software) noted SDD overhead as a barrier. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j) +- **Cross-platform and enterprise** — Windows CI (v0.7.1), GITHUB_TOKEN authentication (v0.8.2), Salesforce-specific extensions, and the iSAQB architecture governance preset indicate growing enterprise adoption. [\[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 index d0e6547816..3a119cbd5f 100644 --- a/presets/ARCHITECTURE.md +++ b/presets/ARCHITECTURE.md @@ -41,6 +41,24 @@ The resolution is implemented three times to ensure consistency: - **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`. diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md index 5e91c4b786..661614e5c0 100644 --- a/presets/PUBLISHING.md +++ b/presets/PUBLISHING.md @@ -205,11 +205,21 @@ Edit `presets/catalog.community.json` and add your preset. } ``` -### 3. Submit Pull Request +### 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 +git add presets/catalog.community.json docs/community/presets.md git commit -m "Add your-preset to community catalog - Preset ID: your-preset @@ -240,6 +250,7 @@ git push origin add-your-preset - [ ] 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 ``` --- diff --git a/presets/README.md b/presets/README.md index dd3997b239..29cce64248 100644 --- a/presets/README.md +++ b/presets/README.md @@ -61,14 +61,44 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa specify preset add pm-workflow --priority 1 # overrides everything ``` -Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely. +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. +> Community presets are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — 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 @@ -93,9 +123,25 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset ## Environment Variables -| Variable | Description | -|----------|-------------| -| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) | +| Variable | Description | Default | +|----------|-------------|---------| +| `SPECKIT_PRESET_CATALOG_URL` | Override the full catalog stack with a single URL (replaces all defaults) | Built-in default stack | +| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated requests to GitHub-hosted URLs (`raw.githubusercontent.com`, `github.com`, `api.github.com`, `codeload.github.com`). Required when your catalog JSON or preset ZIPs are hosted in a private GitHub repository. | None | + +#### Example: Using a private GitHub-hosted catalog + +```bash +# Authenticate with a token (gh CLI, PAT, or GITHUB_TOKEN in CI) +export GITHUB_TOKEN=$(gh auth token) + +# Search a private catalog added via `specify preset catalog add` +specify preset search my-template + +# Install from a private catalog +specify preset add my-template +``` + +The token is attached automatically to requests targeting GitHub domains. Non-GitHub catalog URLs are always fetched without credentials. ## Configuration Files @@ -108,13 +154,5 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset The following enhancements are under consideration for future releases: -- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`: - - | Type | `replace` | `prepend` | `append` | `wrap` | - |------|-----------|-----------|----------|--------| - | **template** | ✓ (default) | ✓ | ✓ | ✓ | - | **command** | ✓ (default) | ✓ | ✓ | ✓ | - | **script** | ✓ (default) | — | — | ✓ | - - For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable. -- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it. +- **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 index 625bc9ed50..bf9725e625 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,8 +1,64 @@ { "schema_version": "1.0", - "updated_at": "2026-04-06T06:30:00Z", + "updated_at": "2026-05-05T10:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { + "a11y-governance": { + "name": "A11Y Governance", + "id": "a11y-governance", + "version": "0.2.0", + "description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, and inclusive-content governance to Spec Kit.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.2.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 9, + "commands": 3 + }, + "tags": [ + "a11y", + "accessibility", + "bilingual", + "wcag", + "inclusion" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, + "agent-parity-governance": { + "name": "Agent Parity Governance", + "id": "agent-parity-governance", + "version": "0.1.0", + "description": "Keeps shared AI-agent guidance aligned across a project-defined set of agent instruction surfaces.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.1.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 6, + "commands": 3 + }, + "tags": [ + "agents", + "governance", + "parity", + "agent-guidance", + "multi-agent" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, "aide-in-place": { "name": "AIDE In-Place Migration", "id": "aide-in-place", @@ -16,7 +72,9 @@ "license": "MIT", "requires": { "speckit_version": ">=0.2.0", - "extensions": ["aide"] + "extensions": [ + "aide" + ] }, "provides": { "templates": 2, @@ -29,6 +87,34 @@ "aide" ] }, + "architecture-governance": { + "name": "Architecture Governance", + "id": "architecture-governance", + "version": "0.2.0", + "description": "Adds secure architecture governance, threat modeling, STRIDE/CAPEC, Zero Trust, S-ADRs, and OWASP SAMM to Spec Kit.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.2.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 11, + "commands": 3 + }, + "tags": [ + "architecture", + "governance", + "threat-modeling", + "stride", + "zero-trust" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, "canon-core": { "name": "Canon Core", "id": "canon-core", @@ -53,6 +139,61 @@ "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" + }, + "cross-platform-governance": { + "name": "Cross-Platform Governance", + "id": "cross-platform-governance", + "version": "0.1.0", + "description": "Adds Bash and PowerShell parity, dry-run/WhatIf parity, man-page expectations, and Verb-Noun Cmdlet discipline.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.1.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 8, + "commands": 3 + }, + "tags": [ + "cross-platform", + "bash", + "powershell", + "man-page", + "cmdlet" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, "explicit-task-dependencies": { "name": "Explicit Task Dependencies", "id": "explicit-task-dependencies", @@ -78,6 +219,125 @@ "wave-dag" ] }, + "fiction-book-writing": { + "name": "Fiction Book Writing", + "id": "fiction-book-writing", + "version": "1.7.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. Support for offline semantic search.", + "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.7.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": 2 + }, + "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-27T08:00:00Z" + }, + "isaqb-architecture-governance": { + "name": "iSAQB Architecture Governance", + "id": "isaqb-architecture-governance", + "version": "0.1.0", + "description": "Adds general iSAQB/CPSA-F and arc42 architecture governance, including views, quality scenarios, ADRs, risks, and technical debt.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.1.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 13, + "commands": 3 + }, + "tags": [ + "architecture", + "governance", + "isaqb", + "arc42", + "adr" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00: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", @@ -103,6 +363,99 @@ "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" + }, + "security-governance": { + "name": "Security Governance", + "id": "security-governance", + "version": "0.2.0", + "description": "Adds secure development governance, MSL preference, ASVS verification, supply-chain transparency, and EU CRA awareness.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-security-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.2.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-security-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 12, + "commands": 3 + }, + "tags": [ + "security", + "governance", + "msl", + "asvs", + "supply-chain" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, + "spec2cloud": { + "name": "Spec2Cloud", + "id": "spec2cloud", + "version": "1.1.0", + "description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.", + "author": "Azure Samples", + "repository": "https://github.com/Azure-Samples/Spec2Cloud", + "download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/preset.zip", + "homepage": "https://aka.ms/spec2cloud", + "documentation": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 5, + "commands": 8 + }, + "tags": [ + "azure", + "spec2cloud", + "workflow", + "deployment" + ], + "created_at": "2026-04-30T00:00:00Z", + "updated_at": "2026-04-30T00:00:00Z" + }, "toc-navigation": { "name": "Table of Contents Navigation", "id": "toc-navigation", diff --git a/presets/catalog.json b/presets/catalog.json index ca40f85280..f272617926 100644 --- a/presets/catalog.json +++ b/presets/catalog.json @@ -1,6 +1,30 @@ { "schema_version": "1.0", - "updated_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-04-24T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json", - "presets": {} + "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/preset.yml b/presets/scaffold/preset.yml index 975a92a413..65111ba9f3 100644 --- a/presets/scaffold/preset.yml +++ b/presets/scaffold/preset.yml @@ -32,6 +32,15 @@ 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" @@ -45,6 +54,26 @@ provides: # 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. 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 index 82c7b068ad..8e718430aa 100644 --- a/presets/self-test/preset.yml +++ b/presets/self-test/preset.yml @@ -56,6 +56,11 @@ provides: 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/pyproject.toml b/pyproject.toml index 9e46b0a14d..d7a949d8b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "specify-cli" -version = "0.5.1.dev0" +version = "0.8.8.dev0" 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", - "click>=8.1", + "typer>=0.24.0", + "click>=8.2.1", "rich", "platformdirs", "readchar", @@ -28,7 +28,6 @@ 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/agent-file-template.md" = "specify_cli/core_pack/templates/agent-file-template.md" "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" @@ -41,6 +40,10 @@ packages = ["src/specify_cli"] "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 = [ diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 5e45e8708c..03141e4462 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -114,8 +114,19 @@ has_git() { 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_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 @@ -124,6 +135,9 @@ check_feature_branch() { 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 @@ -131,7 +145,7 @@ check_feature_branch() { 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: $branch" >&2 + 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 @@ -139,13 +153,65 @@ check_feature_branch() { return 0 } -get_feature_dir() { echo "$1/specs/$2"; } +# 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 prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches) @@ -194,9 +260,29 @@ get_feature_paths() { has_git_repo="true" fi - # Use prefix-based lookup to support multiple branches per spec + # 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 (legacy fallback) local feature_dir - if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then + feature_dir="$SPECIFY_FEATURE_DIRECTORY" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif [[ -f "$repo_root/.specify/feature.json" ]]; then + # Shared, set -e-safe parser: jq -> 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 @@ -281,8 +367,9 @@ 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)): - print(pid) + 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 @@ -334,3 +421,225 @@ except Exception: 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 index f9ba9545df..c3537704f6 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -84,7 +84,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then fi # Trim whitespace and validate description is not empty (e.g., user passed only whitespace) -FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +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 @@ -337,8 +337,11 @@ if [ "$DRY_RUN" != true ]; then if [ "$current_branch" = "$BRANCH_NAME" ]; then : # Otherwise switch to the existing branch instead of failing. - elif ! git checkout "$BRANCH_NAME" 2>/dev/null; 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 diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 9f5523149e..f2d2f6e6fc 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -32,8 +32,10 @@ _paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature p 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" diff --git a/scripts/bash/setup-tasks.sh b/scripts/bash/setup-tasks.sh new file mode 100644 index 0000000000..3f6a40b12d --- /dev/null +++ b/scripts/bash/setup-tasks.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false + +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Validate branch +# 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 + +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 + +if [[ ! -f "$FEATURE_SPEC" ]]; then + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 + exit 1 +fi + +# Build available docs list +docs=() +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Resolve tasks template through override stack +TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true +if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then + echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 + echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2 + exit 1 +fi + +# Output results +if $JSON_MODE; then + 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" \ + --arg tasks_template "${TASKS_TEMPLATE:-}" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}' + 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,"TASKS_TEMPLATE":"%s"}\n' \ + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" + fi +else + echo "FEATURE_DIR: $FEATURE_DIR" + echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}" + echo "AVAILABLE_DOCS:" + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" +fi diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh deleted file mode 100644 index b0ef4b422a..0000000000 --- a/scripts/bash/update-agent-context.sh +++ /dev/null @@ -1,838 +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, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Antigravity or Generic -# - 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|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic -# 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 -_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } -eval "$_paths_output" -unset _paths_output - -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/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" -JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.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, Kiro CLI, IBM Bob, Pi, and Forge all share AGENTS.md — use AGENTS_FILE to avoid -# updating the same file multiple times. -AMP_FILE="$AGENTS_FILE" -SHAI_FILE="$REPO_ROOT/SHAI.md" -TABNINE_FILE="$REPO_ROOT/TABNINE.md" -KIRO_FILE="$AGENTS_FILE" -AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" -BOB_FILE="$AGENTS_FILE" -VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" -KIMI_FILE="$REPO_ROOT/KIMI.md" -TRAE_FILE="$REPO_ROOT/.trae/rules/project_rules.md" -IFLOW_FILE="$REPO_ROOT/IFLOW.md" -FORGE_FILE="$AGENTS_FILE" - -# 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=$? - # Disarm traps to prevent re-entrant loop - trap - EXIT INT TERM - 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" - - # Prepend Cursor frontmatter for .mdc files so rules are auto-included - if [[ "$target_file" == *.mdc ]]; then - local frontmatter_file - frontmatter_file=$(mktemp) || return 1 - printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" - cat "$temp_file" >> "$frontmatter_file" - mv "$frontmatter_file" "$temp_file" - fi - - 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 - - # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion - if [[ "$target_file" == *.mdc ]]; then - if ! head -1 "$temp_file" | grep -q '^---'; then - local frontmatter_file - frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; } - printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" - cat "$temp_file" >> "$frontmatter_file" - mv "$frontmatter_file" "$temp_file" - fi - 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" || return 1 - ;; - gemini) - update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1 - ;; - copilot) - update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1 - ;; - cursor-agent) - update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1 - ;; - qwen) - update_agent_file "$QWEN_FILE" "Qwen Code" || return 1 - ;; - opencode) - update_agent_file "$AGENTS_FILE" "opencode" || return 1 - ;; - codex) - update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1 - ;; - windsurf) - update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1 - ;; - junie) - update_agent_file "$JUNIE_FILE" "Junie" || return 1 - ;; - kilocode) - update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1 - ;; - auggie) - update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1 - ;; - roo) - update_agent_file "$ROO_FILE" "Roo Code" || return 1 - ;; - codebuddy) - update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1 - ;; - qodercli) - update_agent_file "$QODER_FILE" "Qoder CLI" || return 1 - ;; - amp) - update_agent_file "$AMP_FILE" "Amp" || return 1 - ;; - shai) - update_agent_file "$SHAI_FILE" "SHAI" || return 1 - ;; - tabnine) - update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1 - ;; - kiro-cli) - update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1 - ;; - agy) - update_agent_file "$AGY_FILE" "Antigravity" || return 1 - ;; - bob) - update_agent_file "$BOB_FILE" "IBM Bob" || return 1 - ;; - vibe) - update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1 - ;; - kimi) - update_agent_file "$KIMI_FILE" "Kimi Code" || return 1 - ;; - trae) - update_agent_file "$TRAE_FILE" "Trae" || return 1 - ;; - pi) - update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1 - ;; - iflow) - update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1 - ;; - forge) - update_agent_file "$AGENTS_FILE" "Forge" || return 1 - ;; - generic) - log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." - ;; - *) - log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic" - exit 1 - ;; - esac -} - -# Helper: skip non-existent files and files already updated (dedup by -# realpath so that variables pointing to the same file — e.g. AMP_FILE, -# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). -# Uses a linear array instead of associative array for bash 3.2 compatibility. -# Note: defined at top level because bash 3.2 does not support true -# nested/local functions. _updated_paths, _found_agent, and _all_ok are -# initialised exclusively inside update_all_existing_agents so that -# sourcing this script has no side effects on the caller's environment. - -_update_if_new() { - local file="$1" name="$2" - [[ -f "$file" ]] || return 0 - local real_path - real_path=$(realpath "$file" 2>/dev/null || echo "$file") - local p - if [[ ${#_updated_paths[@]} -gt 0 ]]; then - for p in "${_updated_paths[@]}"; do - [[ "$p" == "$real_path" ]] && return 0 - done - fi - # Record the file as seen before attempting the update so that: - # (a) aliases pointing to the same path are not retried on failure - # (b) _found_agent reflects file existence, not update success - _updated_paths+=("$real_path") - _found_agent=true - update_agent_file "$file" "$name" -} - -update_all_existing_agents() { - _found_agent=false - _updated_paths=() - local _all_ok=true - - _update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false - _update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false - _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false - _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false - _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false - _update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge" || _all_ok=false - _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false - _update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false - _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false - _update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false - _update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false - _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false - _update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false - _update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false - _update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false - _update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false - _update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false - _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false - _update_if_new "$TRAE_FILE" "Trae" || _all_ok=false - _update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false - - # 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" || return 1 - fi - - [[ "$_all_ok" == true ]] -} -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|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]" -} - -#============================================================================== -# 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 8c8c801ee3..ffc6d73b3c 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -127,6 +127,16 @@ function Test-HasGit { } } +# 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, @@ -138,29 +148,175 @@ function Test-FeatureBranch { 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}-') { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" + [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 @@ -199,6 +355,21 @@ 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) @@ -227,6 +398,7 @@ function Resolve-Template { $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 } } @@ -266,3 +438,206 @@ function Resolve-Template { 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 3e7e525b86..2f23283fc4 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -315,9 +315,13 @@ if (-not $DryRun) { # Already on the target branch — nothing to do } else { # Otherwise switch to the existing branch instead of failing. - git checkout -q $branchName 2>$null | Out-Null + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { - Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + 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 } } diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index ee09094bf7..15ae557544 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -23,9 +23,11 @@ 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 diff --git a/scripts/powershell/setup-tasks.ps1 b/scripts/powershell/setup-tasks.ps1 new file mode 100644 index 0000000000..e00ae7a02f --- /dev/null +++ b/scripts/powershell/setup-tasks.ps1 @@ -0,0 +1,74 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [switch]$Json, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]" + exit 0 +} + +# Source common functions +. "$PSScriptRoot/common.ps1" + +# Get feature paths and validate branch +$paths = Get-FeaturePathsEnv + +# 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 + } +} + +if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") + [Console]::Error.WriteLine("Run /speckit.plan first to create the implementation plan.") + exit 1 +} + +if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)") + [Console]::Error.WriteLine("Run /speckit.specify first to create the feature structure.") + exit 1 +} + +# Build available docs list +$docs = @() +if (Test-Path $paths.RESEARCH) { $docs += 'research.md' } +if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' } +if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) { + $docs += 'contracts/' +} +if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' } + +# Resolve tasks template through override stack +$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT +if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) { + $expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md' + [Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.") + exit 1 +} +$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path + +# Output results +if ($Json) { + [PSCustomObject]@{ + FEATURE_DIR = $paths.FEATURE_DIR + AVAILABLE_DOCS = $docs + TASKS_TEMPLATE = $tasksTemplate + } | ConvertTo-Json -Compress +} else { + Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)" + Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })" + Write-Output "AVAILABLE_DOCS:" + Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null + Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null + Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null + Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null +} diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 deleted file mode 100644 index 12caa306da..0000000000 --- a/scripts/powershell/update-agent-context.ps1 +++ /dev/null @@ -1,513 +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, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, generic) - -.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','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','generic')] - [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/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' -$JUNIE_FILE = Join-Path $REPO_ROOT '.junie/AGENTS.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' -$TABNINE_FILE = Join-Path $REPO_ROOT 'TABNINE.md' -$KIRO_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' -$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' -$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' -$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.md' -$IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md' -$FORGE_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 - - # Prepend Cursor frontmatter for .mdc files so rules are auto-included - if ($TargetFile -match '\.mdc$') { - $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') -join [Environment]::NewLine - $content = $frontmatter + $content - } - - $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($_) } - } - - # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion - if ($TargetFile -match '\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') { - $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') - $output.InsertRange(0, $frontmatter) - } - - 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' } - 'junie' { Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie' } - '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' } - 'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } - 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } - 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } - 'tabnine' { Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI' } - 'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' } - 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } - 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } - 'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } - 'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' } - 'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' } - 'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' } - 'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' } - 'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' } - 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic'; return $false } - } -} - -function Update-AllExistingAgents { - $found = $false - $ok = $true - $updatedPaths = @() - - # Helper function to update only if file exists and hasn't been updated yet - function Update-IfNew { - param( - [Parameter(Mandatory=$true)] - [string]$FilePath, - [Parameter(Mandatory=$true)] - [string]$AgentName - ) - - if (-not (Test-Path $FilePath)) { return $true } - - # Get the real path to detect duplicates (e.g., AMP_FILE, KIRO_FILE, BOB_FILE all point to AGENTS.md) - $realPath = (Get-Item -LiteralPath $FilePath).FullName - - # Check if we've already updated this file - if ($updatedPaths -contains $realPath) { - return $true - } - - # Record the file as seen before attempting the update - # Use parent scope (1) to modify Update-AllExistingAgents' local variables - Set-Variable -Name updatedPaths -Value ($updatedPaths + $realPath) -Scope 1 - Set-Variable -Name found -Value $true -Scope 1 - - # Perform the update - return (Update-AgentFile -TargetFile $FilePath -AgentName $AgentName) - } - - if (-not (Update-IfNew -FilePath $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false } - if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false } - if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge')) { $ok = $false } - if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false } - if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false } - if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $ROO_FILE -AgentName 'Roo Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $SHAI_FILE -AgentName 'SHAI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AGY_FILE -AgentName 'Antigravity')) { $ok = $false } - if (-not (Update-IfNew -FilePath $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false } - if (-not (Update-IfNew -FilePath $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $TRAE_FILE -AgentName 'Trae')) { $ok = $false } - if (-not (Update-IfNew -FilePath $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false } - - 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|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]' -} - -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/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 95ab2028c1..325692900e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -7,6 +7,8 @@ # "platformdirs", # "readchar", # "json5", +# "pyyaml", +# "packaging", # ] # /// """ @@ -33,9 +35,14 @@ import json import json5 import stat +import shlex +import urllib.error +import urllib.request import yaml from pathlib import Path -from typing import Any, Optional, Tuple + +from packaging.version import InvalidVersion, Version +from typing import Any, Optional import typer from rich.console import Console @@ -47,9 +54,32 @@ from rich.tree import Tree from typer.core import TyperGroup +from .integration_runtime import ( + invoke_separator_for_integration as _invoke_separator_for_integration, + resolve_integration_options as _resolve_integration_options_impl, + with_integration_setting as _with_integration_setting, +) +from .integration_state import ( + INTEGRATION_JSON, + INTEGRATION_STATE_SCHEMA, + dedupe_integration_keys as _dedupe_integration_keys, + default_integration_key as _default_integration_key, + installed_integration_keys as _installed_integration_keys, + integration_setting as _integration_setting, + integration_settings as _integration_settings, + normalize_integration_state as _normalize_integration_state, + write_integration_json as _write_integration_json_file, +) +from .shared_infra import ( + install_shared_infra as _install_shared_infra_impl, + refresh_shared_templates as _refresh_shared_templates_impl, +) + # For cross-platform keyboard input import readchar +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 @@ -60,6 +90,7 @@ def _build_agent_config() -> dict[str, dict[str, Any]]: return config AGENT_CONFIG = _build_agent_config() +DEFAULT_INIT_INTEGRATION = "copilot" AI_ASSISTANT_ALIASES = { "kiro": "kiro-cli", @@ -92,6 +123,39 @@ def _build_ai_assistant_help() -> str: 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 0.10.0 or later.\n\n" + f"Use [bold]{replacement}[/bold] instead." + ) + +def _stdin_is_interactive() -> bool: + return sys.stdin.isatty() + SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" @@ -287,7 +351,7 @@ def run_selection_loop(): return selected_key -console = Console() +console = Console(highlight=False) class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" @@ -320,8 +384,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() @@ -384,6 +456,7 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: return found + def is_git_repo(path: Path = None) -> bool: """Check if the specified path is inside a git repository.""" if path is None: @@ -393,7 +466,6 @@ def is_git_repo(path: Path = None) -> bool: return False try: - # Use git command to check if inside a work tree subprocess.run( ["git", "rev-parse", "--is-inside-work-tree"], check=True, @@ -404,16 +476,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) @@ -425,20 +490,19 @@ 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. @@ -604,6 +668,11 @@ def _locate_core_pack() -> Path | None: return None +def _repo_root() -> Path: + """Return the source checkout root used for editable installs.""" + return Path(__file__).parent.parent.parent + + def _locate_bundled_extension(extension_id: str) -> Path | None: """Return the path to a bundled extension, or None. @@ -621,128 +690,174 @@ def _locate_bundled_extension(extension_id: str) -> Path | None: return candidate # Source-checkout / editable install: look relative to repo root - repo_root = Path(__file__).parent.parent.parent - candidate = repo_root / "extensions" / extension_id + candidate = _repo_root() / "extensions" / extension_id if (candidate / "extension.yml").is_file(): return candidate return None +def _locate_bundled_workflow(workflow_id: str) -> Path | None: + """Return the path to a bundled workflow directory, or None. + + Checks the wheel's core_pack first, then falls back to the + source-checkout ``workflows//`` directory. + """ + import re as _re + if not _re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id): + return None + + core = _locate_core_pack() + if core is not None: + candidate = core / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate + + # Source-checkout / editable install: look relative to repo root + candidate = _repo_root() / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate + + return None + + +def _locate_bundled_preset(preset_id: str) -> Path | None: + """Return the path to a bundled preset, or None. + + Checks the wheel's core_pack first, then falls back to the + source-checkout ``presets//`` directory. + """ + import re as _re + if not _re.match(r'^[a-z0-9-]+$', preset_id): + return None + + core = _locate_core_pack() + if core is not None: + candidate = core / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate + + # Source-checkout / editable install: look relative to repo root + candidate = _repo_root() / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate + + return None + + +def _refresh_shared_templates( + project_path: Path, + *, + invoke_separator: str, + force: bool = False, +) -> None: + """Refresh default-sensitive shared templates without touching scripts.""" + _refresh_shared_templates_impl( + project_path, + version=get_speckit_version(), + core_pack=_locate_core_pack(), + repo_root=_repo_root(), + console=console, + invoke_separator=invoke_separator, + force=force, + ) + + 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*. 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. + Returns ``True`` on success. """ - from .integrations.manifest import IntegrationManifest + return _install_shared_infra_impl( + project_path, + script_type, + version=get_speckit_version(), + core_pack=_locate_core_pack(), + repo_root=_repo_root(), + console=console, + force=force, + invoke_separator=invoke_separator, + ) - core = _locate_core_pack() - manifest = IntegrationManifest("speckit", project_path, version=get_speckit_version()) - # Scripts - if core and (core / "scripts").is_dir(): - scripts_src = core / "scripts" - else: - repo_root = Path(__file__).parent.parent.parent - scripts_src = repo_root / "scripts" - - skipped_files: list[str] = [] - - if scripts_src.is_dir(): - dest_scripts = project_path / ".specify" / "scripts" - dest_scripts.mkdir(parents=True, exist_ok=True) - variant_dir = "bash" if script_type == "sh" else "powershell" - variant_src = scripts_src / variant_dir - if variant_src.is_dir(): - dest_variant = dest_scripts / variant_dir - dest_variant.mkdir(parents=True, exist_ok=True) - # Merge without overwriting — only add files that don't exist yet - for src_path in variant_src.rglob("*"): - if src_path.is_file(): - rel_path = src_path.relative_to(variant_src) - dst_path = dest_variant / rel_path - if dst_path.exists(): - skipped_files.append(str(dst_path.relative_to(project_path))) - else: - dst_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src_path, dst_path) - rel = dst_path.relative_to(project_path).as_posix() - manifest.record_existing(rel) - - # Page templates (not command templates, not vscode-settings.json) - if core and (core / "templates").is_dir(): - templates_src = core / "templates" - else: - repo_root = Path(__file__).parent.parent.parent - templates_src = repo_root / "templates" - - if templates_src.is_dir(): - dest_templates = project_path / ".specify" / "templates" - dest_templates.mkdir(parents=True, exist_ok=True) - for f in templates_src.iterdir(): - if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."): - dst = dest_templates / f.name - if dst.exists(): - skipped_files.append(str(dst.relative_to(project_path))) - else: - shutil.copy2(f, dst) - rel = dst.relative_to(project_path).as_posix() - manifest.record_existing(rel) - - if skipped_files: - import logging - logging.getLogger(__name__).warning( - "The following shared files already exist and were not overwritten:\n%s", - "\n".join(f" {f}" for f in skipped_files), +def _install_shared_infra_or_exit( + project_path: Path, + script_type: str, + tracker: StepTracker | None = None, + force: bool = False, + invoke_separator: str = ".", +) -> bool: + try: + return _install_shared_infra( + project_path, + script_type, + tracker=tracker, + force=force, + invoke_separator=invoke_separator, ) - - manifest.save() - return True + except (ValueError, OSError) as exc: + console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}") + raise typer.Exit(1) 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"{_display_project_path(project_path, script)}: {e}") if tracker: detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") tracker.add("chmod", "Set script permissions recursively") @@ -835,7 +950,6 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: # Constants kept for backward compatibility with presets and extensions. DEFAULT_SKILLS_DIR = ".agents/skills" -NATIVE_SKILLS_AGENTS = {"codex", "kimi"} SKILL_DESCRIPTIONS = { "specify": "Create or update feature specifications from natural language descriptions.", "plan": "Generate technical implementation plans from feature specifications.", @@ -855,7 +969,7 @@ def init( 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"), + ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), @@ -885,45 +999,46 @@ def init( This command will: 1. Check that required tools are installed (git is optional) - 2. Let you choose your AI assistant + 2. Let you choose your coding agent integration, or default to Copilot + in non-interactive sessions 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 + 5. Optionally set up coding agent integration commands Examples: specify init my-project - specify init my-project --ai claude - specify init my-project --ai copilot --no-git + specify init my-project --integration claude + specify init my-project --integration copilot --no-git specify init --ignore-agent-tools my-project - specify init . --ai claude # Initialize in current directory - specify init . # Initialize in current directory (interactive AI selection) - specify init --here --ai claude # Alternative syntax for current directory - specify init --here --ai codex --ai-skills - specify init --here --ai codebuddy - specify init --here --ai vibe # Initialize with Mistral Vibe support + specify init . --integration claude # Initialize in current directory + specify init . # Initialize in current directory (interactive integration selection) + specify init --here --integration claude # Alternative syntax for current directory + specify init --here --integration codex --integration-options="--skills" + specify init --here --integration codebuddy + specify init --here --integration 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 + specify init my-project --integration claude # Claude installs skills by default + specify init --here --integration gemini + specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir + specify init my-project --integration 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("[yellow]Example:[/yellow] specify init --integration claude --here") console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}") raise typer.Exit(1) if ai_commands_dir and ai_commands_dir.startswith("--"): console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'") console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?") - console.print("[yellow]Example:[/yellow] specify init --ai generic --ai-commands-dir .myagent/commands/") + console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"") raise typer.Exit(1) if ai_assistant: @@ -949,6 +1064,10 @@ def init( 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) @@ -971,6 +1090,13 @@ def init( 'use [bold]--integration generic --integration-options="--commands-dir "[/bold] instead.[/dim]' ) + if no_git: + console.print( + "[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n" + "[yellow]The git extension will no longer be enabled by default " + "— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]" + ) + if project_name == ".": here = True project_name = None # Clear project_name to use existing validation logic @@ -993,9 +1119,11 @@ def init( 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: @@ -1010,30 +1138,48 @@ 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) - raise typer.Exit(1) + 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 + elif not _stdin_is_interactive(): + console.print( + f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. " + "Use --integration to choose a different agent.[/dim]" + ) + selected_ai = DEFAULT_INIT_INTEGRATION 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" + "Choose your coding agent integration:", + DEFAULT_INIT_INTEGRATION, ) # Auto-promote interactively selected agents to the integration path @@ -1098,12 +1244,12 @@ def init( else: default_script = "ps" if os.name == "nt" else "sh" - if sys.stdin.isatty(): + if _stdin_is_interactive(): selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script) else: selected_script = default_script - console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") + console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") tracker = StepTracker("Initialize Specify Project") @@ -1112,7 +1258,7 @@ def init( tracker.add("precheck", "Check required tools") tracker.complete("precheck", "ok") - tracker.add("ai-select", "Select AI assistant") + tracker.add("ai-select", "Select coding agent integration") tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) @@ -1123,13 +1269,13 @@ def init( for key, label in [ ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), - ("git", "Initialize git repository"), + ("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 + git_default_notice = False with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) @@ -1149,6 +1295,12 @@ def init( 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, @@ -1158,45 +1310,122 @@ def init( ) manifest.save() - # Write .specify/integration.json - script_ext = "sh" if selected_script == "sh" else "ps1" - 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(), - "scripts": { - "update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}", - }, - }, indent=2) + "\n", encoding="utf-8") + integration_settings = _with_integration_setting( + {}, + resolved_integration.key, + resolved_integration, + script_type=selected_script, + raw_options=integration_options, + parsed_options=integration_parsed_options or None, + ) + _write_integration_json( + project_path, + resolved_integration.key, + [resolved_integration.key], + integration_settings, + ) 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) + _install_shared_infra_or_exit( + 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_executable_scripts(project_path, tracker=tracker) - 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_default_notice = True + 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") + # 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. @@ -1204,15 +1433,17 @@ def init( "ai": selected_ai, "integration": resolved_integration.key, "branch_numbering": branch_numbering or "sequential", + "context_file": resolved_integration.context_file, "here": here, - "preset": preset, "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): + if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): init_opts["ai_skills"] = True save_init_options(project_path, init_opts) @@ -1223,27 +1454,44 @@ def init( preset_manager = PresetManager(project_path) speckit_ver = get_speckit_version() - # Try local directory first, then catalog + # 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: - 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.") + bundled_path = _locate_bundled_preset(preset) + if bundled_path: + preset_manager.install_from_directory(bundled_path, speckit_ver) else: - try: - zip_path = preset_catalog.download_pack(preset) - preset_manager.install_from_zip(zip_path, speckit_ver) - # Clean up downloaded ZIP to avoid cache accumulation + 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.unlink(missing_ok=True) - except OSError: - # Best-effort cleanup; failure to delete is non-fatal - pass - except PresetError as preset_err: - console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") + 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}") @@ -1262,7 +1510,7 @@ def init( _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: @@ -1271,23 +1519,6 @@ 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: @@ -1303,6 +1534,28 @@ def init( console.print() console.print(security_notice) + if ai_deprecation_warning: + deprecation_notice = Panel( + ai_deprecation_warning, + title="[bold red]Deprecation Warning[/bold red]", + border_style="red", + padding=(1, 2), + ) + console.print() + console.print(deprecation_notice) + + if git_default_notice: + default_change_notice = Panel( + "The git extension is currently enabled by default during [bold]specify init[/bold].\n" + "Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n" + "Use [bold]specify extension add git[/bold] after init when needed.", + title="[yellow]Notice: Git Default Changing[/yellow]", + border_style="yellow", + padding=(1, 2), + ) + console.print() + console.print(default_change_notice) + steps_lines = [] if not here: steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") @@ -1312,16 +1565,19 @@ def init( step_num = 2 # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, kimi, agy, trae) should show skill invocation syntax. + # Skills integrations (codex, claude, kimi, agy, trae, cursor-agent, copilot, devin) should show skill invocation syntax. from .integrations.base import SkillsIntegration as _SkillsInt - _is_skills_integration = isinstance(resolved_integration, _SkillsInt) + _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" - native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode + cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) + copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration + devin_skill_mode = selected_ai == "devin" + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode if codex_skill_mode and not ai_skills: # Integration path installed skills; show the helpful notice @@ -1330,6 +1586,12 @@ def init( 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 + if devin_skill_mode: + steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]") + step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: @@ -1339,9 +1601,11 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" + if cursor_agent_skill_mode or copilot_skill_mode or devin_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}. Start using {usage_label} with your coding agent:") steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles") steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification") @@ -1412,31 +1676,16 @@ def check(): console.print("[dim]Tip: Install git for repository management[/dim]") if not any(agent_results.values()): - console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]") + console.print("[dim]Tip: Install a coding agent for the best experience[/dim]") @app.command() def version(): """Display version and system information.""" import platform - import importlib.metadata show_banner() - # Get CLI version from package metadata - cli_version = "unknown" - try: - cli_version = importlib.metadata.version("specify-cli") - except Exception: - # Fallback: try reading from pyproject.toml if running from source - try: - import tomllib - pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" - if pyproject_path.exists(): - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) - cli_version = data.get("project", {}).get("version", "unknown") - except Exception: - pass + 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") @@ -1459,6 +1708,157 @@ 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). + """ + from .authentication.http import open_url + + try: + with open_url( + GITHUB_API_LATEST, + timeout=5, + extra_headers={"Accept": "application/vnd.github+json"}, + ) 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 (configure ~/.specify/auth.json with a 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 ===== @@ -1500,7 +1900,7 @@ def get_speckit_version() -> str: # Fallback: try reading from pyproject.toml try: import tomllib - pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" + pyproject_path = _repo_root() / "pyproject.toml" if pyproject_path.exists(): with open(pyproject_path, "rb") as f: data = tomllib.load(f) @@ -1516,17 +1916,21 @@ def get_speckit_version() -> str: integration_app = typer.Typer( name="integration", - help="Manage AI agent integrations", + help="Manage coding agent integrations", add_completion=False, ) app.add_typer(integration_app, name="integration") - -INTEGRATION_JSON = ".specify/integration.json" +integration_catalog_app = typer.Typer( + name="catalog", + help="Manage integration catalog sources", + add_completion=False, +) +integration_app.add_typer(integration_catalog_app, name="catalog") def _read_integration_json(project_root: Path) -> dict[str, Any]: - """Load ``.specify/integration.json``. Returns ``{}`` when missing.""" + """Load ``.specify/integration.json``. Returns normalized state when present.""" path = project_root / INTEGRATION_JSON if not path.exists(): return {} @@ -1546,25 +1950,42 @@ def _read_integration_json(project_root: Path) -> dict[str, Any]: 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 + schema = data.get("integration_state_schema") + if isinstance(schema, int) and not isinstance(schema, bool) and schema > INTEGRATION_STATE_SCHEMA: + console.print( + f"[red]Error:[/red] {path} uses integration state schema {schema}, " + f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}." + ) + console.print("Please upgrade Spec Kit before modifying integrations.") + raise typer.Exit(1) + return _normalize_integration_state(data) def _write_integration_json( project_root: Path, - integration_key: str, - script_type: str, + integration_key: str | None, + installed_integrations: list[str] | None = None, + integration_settings: dict[str, dict[str, Any]] | None = None, ) -> None: - """Write ``.specify/integration.json`` for *integration_key*.""" - script_ext = "sh" if script_type == "sh" else "ps1" - 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(), - "scripts": { - "update-context": f".specify/integrations/{integration_key}/scripts/update-context.{script_ext}", - }, - }, indent=2) + "\n", encoding="utf-8") + """Write ``.specify/integration.json`` with legacy-compatible state.""" + _write_integration_json_file( + project_root, + version=get_speckit_version(), + integration_key=integration_key, + installed_integrations=installed_integrations, + settings=integration_settings, + ) + + +def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: + """Clear active integration keys from init-options.json when they match.""" + opts = load_init_options(project_root) + if opts.get("integration") == integration_key or opts.get("ai") == integration_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) def _remove_integration_json(project_root: Path) -> None: @@ -1574,6 +1995,13 @@ def _remove_integration_json(project_root: Path) -> None: path.unlink() +_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) + + +class _SharedTemplateRefreshError(RuntimeError): + """Raised when default integration metadata should not be persisted.""" + + 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() @@ -1597,46 +2025,204 @@ def _resolve_script_type(project_root: Path, script_type: str | None) -> str: return "ps" if os.name == "nt" else "sh" -@integration_app.command("list") -def integration_list(): - """List available integrations and installed status.""" - from .integrations import INTEGRATION_REGISTRY - - project_root = Path.cwd() +def _resolve_integration_script_type( + project_root: Path, + state: dict[str, Any], + key: str, + script_type: str | None = None, +) -> str: + """Resolve script type for an integration, preferring stored settings.""" + if script_type: + return _normalize_script_type(script_type, "--script") - 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) + stored = _integration_setting(state, key).get("script") + if isinstance(stored, str) and stored.strip(): + return _normalize_script_type(stored, f"{INTEGRATION_JSON} integration_settings.{key}.script") - current = _read_integration_json(project_root) - installed_key = current.get("integration") + return _resolve_script_type(project_root, None) - table = Table(title="AI Agent Integrations") - table.add_column("Key", style="cyan") - table.add_column("Name") - table.add_column("Status") - table.add_column("CLI Required") - for key in sorted(INTEGRATION_REGISTRY.keys()): - integration = INTEGRATION_REGISTRY[key] - cfg = integration.config or {} - name = cfg.get("name", key) - requires_cli = cfg.get("requires_cli", False) +def _resolve_integration_options( + integration: Any, + state: dict[str, Any], + key: str, + raw_options: str | None, +) -> tuple[str | None, dict[str, Any] | None]: + """Resolve raw and parsed options for an integration operation.""" + return _resolve_integration_options_impl( + integration, + state, + key, + raw_options, + parse_options=_parse_integration_options, + ) - if key == installed_key: - status = "[green]installed[/green]" - else: - status = "" - cli_req = "yes" if requires_cli else "no (IDE)" - table.add_row(key, name, status, cli_req) +def _set_default_integration( + project_root: Path, + state: dict[str, Any], + key: str, + integration: Any, + installed_keys: list[str], + *, + script_type: str | None = None, + raw_options: str | None = None, + parsed_options: dict[str, Any] | None = None, + refresh_templates: bool = True, + refresh_templates_force: bool = False, +) -> None: + """Persist *key* as default and align active runtime metadata.""" + resolved_script = _resolve_integration_script_type(project_root, state, key, script_type) + settings = _with_integration_setting( + state, + key, + integration, + script_type=resolved_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + + if refresh_templates: + try: + _refresh_shared_templates( + project_root, + invoke_separator=_invoke_separator_for_integration( + integration, {"integration_settings": settings}, key, parsed_options + ), + force=refresh_templates_force, + ) + except (ValueError, OSError) as exc: + raise _SharedTemplateRefreshError( + f"Failed to refresh shared templates for '{key}': {exc}" + ) from exc + + _write_integration_json(project_root, key, installed_keys, settings) + _update_init_options_for_integration(project_root, integration, script_type=resolved_script) + + +def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None: + try: + _set_default_integration(*args, **kwargs) + except _SharedTemplateRefreshError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + +def _display_project_path(project_root: Path, path: str | Path) -> str: + """Return a stable POSIX-style display path for paths under a project.""" + path_obj = Path(path) + try: + rel_path = path_obj.relative_to(project_root) if path_obj.is_absolute() else path_obj + except ValueError: + try: + rel_path = path_obj.resolve().relative_to(project_root.resolve()) + except (OSError, ValueError): + return path_obj.as_posix() + return rel_path.as_posix() + + +def _require_specify_project() -> Path: + """Return the current project root if it is a spec-kit project, else exit.""" + project_root = Path.cwd() + if (project_root / ".specify").is_dir(): + return project_root + 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.command("list") +def integration_list( + catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), +): + """List available integrations and installed status.""" + from .integrations import INTEGRATION_REGISTRY + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + default_key = _default_integration_key(current) + installed_keys = set(_installed_integration_keys(current)) + + if catalog: + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + ic = IntegrationCatalog(project_root) + try: + entries = ic.search() + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not entries: + console.print("[yellow]No integrations found in catalog.[/yellow]") + return + + table = Table(title="Integration Catalog") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Version") + table.add_column("Source") + table.add_column("Status") + table.add_column("Multi-install Safe") + + 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 == default_key: + status = "[green]installed (default)[/green]" + elif eid in installed_keys: + status = "[green]installed[/green]" + elif eid in INTEGRATION_REGISTRY: + status = "built-in" + elif install_allowed is False: + status = "discovery-only" + else: + status = "" + safe = "" + if eid in INTEGRATION_REGISTRY: + safe = "yes" if getattr(INTEGRATION_REGISTRY[eid], "multi_install_safe", False) else "no" + table.add_row( + eid, + entry.get("name", eid), + entry.get("version", ""), + cat_name, + status, + safe, + ) + + console.print(table) + return + + table = Table(title="Coding Agent Integrations") + table.add_column("Key", style="cyan") + table.add_column("Name") + table.add_column("Status") + table.add_column("CLI Required") + table.add_column("Multi-install Safe") + + for key in sorted(INTEGRATION_REGISTRY.keys()): + integration = INTEGRATION_REGISTRY[key] + cfg = integration.config or {} + name = cfg.get("name", key) + requires_cli = cfg.get("requires_cli", False) + + if key == default_key: + status = "[green]installed (default)[/green]" + elif key in installed_keys: + status = "[green]installed[/green]" + else: + status = "" + + cli_req = "yes" if requires_cli else "no (IDE)" + safe = "yes" if getattr(integration, "multi_install_safe", False) else "no" + table.add_row(key, name, status, cli_req, safe) console.print(table) - if installed_key: - console.print(f"\n[dim]Current integration:[/dim] [cyan]{installed_key}[/cyan]") + if installed_keys: + console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]") + console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]") else: console.print("\n[yellow]No integration currently installed.[/yellow]") console.print("Install one with: [cyan]specify integration install [/cyan]") @@ -1646,20 +2232,14 @@ def integration_list(): 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)"), + force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"), integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), ): """Install an integration into an existing project.""" from .integrations import INTEGRATION_REGISTRY, get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() integration = get_integration(key) if integration is None: console.print(f"[red]Error:[/red] Unknown integration '{key}'") @@ -1668,23 +2248,68 @@ def integration_install( raise typer.Exit(1) current = _read_integration_json(project_root) - installed_key = current.get("integration") + default_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) - if installed_key and installed_key == key: + if key in installed_keys: console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") - console.print("Run [cyan]specify integration uninstall[/cyan] first, then reinstall.") + console.print( + f"Run [cyan]specify integration upgrade {key}[/cyan] to reinstall managed files, " + f"or [cyan]specify integration uninstall {key}[/cyan] first." + ) raise typer.Exit(0) - if installed_key: - console.print(f"[red]Error:[/red] Integration '{installed_key}' is already installed.") - console.print(f"Run [cyan]specify integration uninstall[/cyan] first, or use [cyan]specify integration switch {key}[/cyan].") - raise typer.Exit(1) + if installed_keys and not force: + unsafe_keys = [] + for installed_key in installed_keys: + installed_integration = get_integration(installed_key) + if not installed_integration or not getattr(installed_integration, "multi_install_safe", False): + unsafe_keys.append(installed_key) + if unsafe_keys or not getattr(integration, "multi_install_safe", False): + console.print( + f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}." + ) + if default_key: + console.print(f"Default integration: [cyan]{default_key}[/cyan].") + console.print( + "Installing multiple integrations is only automatic when all involved " + "integrations are declared multi-install safe." + ) + console.print( + f"Run [cyan]specify integration switch {key}[/cyan] to replace the default " + f"integration, or retry with [cyan]--force[/cyan] to opt in." + ) + raise typer.Exit(1) selected_script = _resolve_script_type(project_root, script) + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + integration, current, key, 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) + infra_integration = integration + infra_key = key + infra_parsed = parsed_options + if default_key: + default_integration = get_integration(default_key) + if default_integration is not None: + infra_integration = default_integration + infra_key = default_key + _, infra_parsed = _resolve_integration_options( + default_integration, current, default_key, None + ) + _install_shared_infra_or_exit( + project_root, + selected_script, + invoke_separator=_invoke_separator_for_integration( + infra_integration, current, infra_key, infra_parsed + ), + ) if os.name != "nt": ensure_executable_scripts(project_root) @@ -1692,21 +2317,27 @@ def integration_install( integration.key, project_root, version=get_speckit_version() ) - # Build parsed options from --integration-options - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(integration, integration_options) - try: integration.setup( project_root, manifest, parsed_options=parsed_options, script_type=selected_script, - raw_options=integration_options, + raw_options=raw_options, ) manifest.save() - _write_integration_json(project_root, integration.key, selected_script) - _update_init_options_for_integration(project_root, integration, script_type=selected_script) + new_installed = _dedupe_integration_keys([*installed_keys, integration.key]) + new_default = default_key or integration.key + settings = _with_integration_setting( + current, + integration.key, + integration, + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + _write_integration_json(project_root, new_default, new_installed, settings) + if new_default == 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 @@ -1715,12 +2346,19 @@ def integration_install( 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) + if installed_keys: + _write_integration_json( + project_root, default_key, installed_keys, _integration_settings(current) + ) + else: + _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") + if default_key: + console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]") def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: @@ -1782,15 +2420,54 @@ def _update_init_options_for_integration( 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): + if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): opts["ai_skills"] = True else: opts.pop("ai_skills", None) save_init_options(project_root, opts) +@integration_app.command("use") +def integration_use( + key: str = typer.Argument(help="Installed integration key to make the default"), + force: bool = typer.Option(False, "--force", help="Overwrite managed shared templates while changing the default"), +): + """Set the default integration without uninstalling other integrations.""" + from .integrations import get_integration + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + installed_keys = _installed_integration_keys(current) + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + if installed_keys: + console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}") + else: + console.print("Install one with: [cyan]specify integration install [/cyan]") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) + + raw_options, parsed_options = _resolve_integration_options(integration, current, key, None) + _set_default_integration_or_exit( + project_root, + current, + key, + integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=force, + ) + console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") + + @integration_app.command("uninstall") def integration_uninstall( key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), @@ -1800,25 +2477,19 @@ def integration_uninstall( from .integrations import get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() current = _read_integration_json(project_root) - installed_key = current.get("integration") + default_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) if key is None: - if not installed_key: + if not default_key: console.print("[yellow]No integration is currently installed.[/yellow]") raise typer.Exit(0) - key = installed_key + key = default_key - if installed_key and installed_key != key: - console.print(f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}').") + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") raise typer.Exit(1) integration = get_integration(key) @@ -1826,19 +2497,35 @@ def integration_uninstall( 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) - save_init_options(project_root, opts) + remaining = [installed for installed in installed_keys if installed != key] + new_default = default_key if default_key != key else (remaining[0] if remaining else None) + if remaining: + if default_key == key and new_default and (new_integration := get_integration(new_default)): + raw_options, parsed_options = _resolve_integration_options( + new_integration, current, new_default, None + ) + _set_default_integration_or_exit( + project_root, + current, + new_default, + new_integration, + remaining, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, new_default, remaining, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + if default_key == key: + _clear_init_options_for_integration(project_root, key) raise typer.Exit(0) try: manifest = IntegrationManifest.load(key, project_root) - except (ValueError, FileNotFoundError) as exc: + except _MANIFEST_READ_ERRORS as exc: console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.") console.print(f"Manifest: {manifest_path}") console.print( @@ -1851,15 +2538,35 @@ def integration_uninstall( removed, skipped = manifest.uninstall(project_root, force=force) - _remove_integration_json(project_root) + # Remove managed context section from the agent context file + if integration: + integration.remove_context_section(project_root) + + remaining = [installed for installed in installed_keys if installed != key] + new_default = default_key if default_key != key else (remaining[0] if remaining else None) + if remaining: + if default_key == key and new_default and (new_integration := get_integration(new_default)): + raw_options, parsed_options = _resolve_integration_options( + new_integration, current, new_default, None + ) + _set_default_integration_or_exit( + project_root, + current, + new_default, + new_integration, + remaining, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, new_default, remaining, _integration_settings(current) + ) + else: + _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) - save_init_options(project_root, opts) + if default_key == key: + _clear_init_options_for_integration(project_root, key) name = (integration.config or {}).get("name", key) if integration else key console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled") @@ -1868,7 +2575,7 @@ def integration_uninstall( 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 + rel = _display_project_path(project_root, path) console.print(f" {rel}") @@ -1883,14 +2590,7 @@ def integration_switch( from .integrations import INTEGRATION_REGISTRY, get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() target_integration = get_integration(target) if target_integration is None: console.print(f"[red]Error:[/red] Unknown integration '{target}'") @@ -1899,10 +2599,67 @@ def integration_switch( raise typer.Exit(1) current = _read_integration_json(project_root) - installed_key = current.get("integration") + installed_keys = _installed_integration_keys(current) + installed_key = _default_integration_key(current) if installed_key == target: - console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]") + if integration_options is not None: + console.print( + "[red]Error:[/red] --integration-options cannot be used when switching " + "to an already installed integration." + ) + console.print( + f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " + "to update managed files/options." + ) + raise typer.Exit(1) + if force: + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, None + ) + _set_default_integration_or_exit( + project_root, + current, + target, + target_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=True, + ) + console.print( + f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; " + "managed shared templates refreshed." + ) + raise typer.Exit(0) + console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]") + raise typer.Exit(0) + + if target in installed_keys: + if integration_options is not None: + console.print( + "[red]Error:[/red] --integration-options cannot be used when switching " + "to an already installed integration." + ) + console.print( + f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] " + f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]." + ) + raise typer.Exit(1) + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, None + ) + _set_default_integration_or_exit( + project_root, + current, + target, + target_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + refresh_templates_force=force, + ) + console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].") raise typer.Exit(0) selected_script = _resolve_script_type(project_root, script) @@ -1916,7 +2673,7 @@ def integration_switch( console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]") try: old_manifest = IntegrationManifest.load(installed_key, project_root) - except (ValueError, FileNotFoundError) as exc: + except _MANIFEST_READ_ERRORS 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( @@ -1925,6 +2682,7 @@ def integration_switch( ) 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: @@ -1939,7 +2697,7 @@ def integration_switch( 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: + except _MANIFEST_READ_ERRORS 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.") @@ -1949,17 +2707,62 @@ def integration_switch( ) raise typer.Exit(1) + # Unregister extension commands for the old agent so they don't + # remain as orphans in the old agent's directory. + try: + from .extensions import ExtensionManager + + ext_mgr = ExtensionManager(project_root) + ext_mgr.unregister_agent_artifacts(installed_key) + except Exception as ext_err: + console.print( + f"[yellow]Warning:[/yellow] Could not clean up extension artifacts " + f"(commands, skills, registry entries) for '{installed_key}': {ext_err}" + ) + # 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) - save_init_options(project_root, opts) + installed_keys = [installed for installed in installed_keys if installed != installed_key] + _clear_init_options_for_integration(project_root, installed_key) + if installed_keys: + fallback_key = installed_keys[0] + fallback_integration = get_integration(fallback_key) + if fallback_integration is not None: + raw_options, parsed_options = _resolve_integration_options( + fallback_integration, current, fallback_key, None + ) + _set_default_integration_or_exit( + project_root, + current, + fallback_key, + fallback_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + ) + else: + _write_integration_json( + project_root, fallback_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) + current = _read_integration_json(project_root) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, 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) + _install_shared_infra_or_exit( + project_root, + selected_script, + invoke_separator=_invoke_separator_for_integration( + target_integration, current, target, parsed_options + ), + ) if os.name != "nt": ensure_executable_scripts(project_root) @@ -1969,20 +2772,37 @@ def integration_switch( target_integration.key, project_root, version=get_speckit_version() ) - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(target_integration, integration_options) - try: target_integration.setup( project_root, manifest, parsed_options=parsed_options, script_type=selected_script, - raw_options=integration_options, + raw_options=raw_options, ) manifest.save() - _write_integration_json(project_root, target_integration.key, selected_script) - _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) + _set_default_integration( + project_root, + current, + target_integration.key, + target_integration, + _dedupe_integration_keys([*installed_keys, target_integration.key]), + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + + # Re-register extension commands for the new agent so that + # previously-installed extensions are available in the new integration. + try: + from .extensions import ExtensionManager + + ext_mgr = ExtensionManager(project_root) + ext_mgr.register_enabled_extensions_for_agent(target) + except Exception as ext_err: + console.print( + f"[yellow]Warning:[/yellow] Could not register extension commands, skills, " + f"or related artifacts for '{target}': {ext_err}" + ) except Exception as e: # Attempt rollback of any files written by setup @@ -1991,7 +2811,34 @@ def integration_switch( 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) + if installed_keys: + fallback_key = installed_keys[0] + fallback_integration = get_integration(fallback_key) + if fallback_integration is not None: + raw_options, parsed_options = _resolve_integration_options( + fallback_integration, current, fallback_key, None + ) + try: + _set_default_integration( + project_root, + current, + fallback_key, + fallback_integration, + installed_keys, + raw_options=raw_options, + parsed_options=parsed_options, + ) + except _SharedTemplateRefreshError as restore_err: + console.print( + f"[yellow]Warning:[/yellow] Failed to restore default " + f"integration '{fallback_key}': {restore_err}" + ) + else: + _write_integration_json( + project_root, fallback_key, installed_keys, _integration_settings(current) + ) + else: + _remove_integration_json(project_root) console.print(f"[red]Error:[/red] Failed to install integration '{target}': {e}") raise typer.Exit(1) @@ -1999,6 +2846,451 @@ def integration_switch( console.print(f"\n[green]✓[/green] Switched to integration '{name}'") +@integration_app.command("upgrade") +def integration_upgrade( + key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), + force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), + script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"), +): + """Upgrade an integration by reinstalling with diff-aware file handling. + + Compares manifest hashes to detect locally modified files and + blocks the upgrade unless --force is used. + """ + from .integrations import get_integration + from .integrations.manifest import IntegrationManifest + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + installed_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) + + if key is None: + if not installed_key: + console.print("[yellow]No integration is currently installed.[/yellow]") + raise typer.Exit(0) + key = installed_key + + if key not in installed_keys: + console.print(f"[red]Error:[/red] Integration '{key}' is not installed.") + raise typer.Exit(1) + + integration = get_integration(key) + if integration is None: + console.print(f"[red]Error:[/red] Unknown integration '{key}'") + raise typer.Exit(1) + + manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json" + if not manifest_path.exists(): + console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]") + console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.") + raise typer.Exit(0) + + try: + old_manifest = IntegrationManifest.load(key, project_root) + except _MANIFEST_READ_ERRORS as exc: + console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}") + raise typer.Exit(1) + + # Detect modified files via manifest hashes + modified = old_manifest.check_modified() + if modified and not force: + console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:") + for rel in modified: + console.print(f" {rel}") + console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.") + raise typer.Exit(1) + + selected_script = _resolve_integration_script_type(project_root, current, key, script) + + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + raw_options, parsed_options = _resolve_integration_options( + integration, current, key, integration_options + ) + + # Ensure shared infrastructure is up to date; --force overwrites existing files. + infra_integration = integration + infra_key = key + infra_parsed = parsed_options + if installed_key and installed_key != key: + default_integration = get_integration(installed_key) + if default_integration is not None: + infra_integration = default_integration + infra_key = installed_key + _, infra_parsed = _resolve_integration_options( + default_integration, current, installed_key, None + ) + _install_shared_infra_or_exit( + project_root, + selected_script, + force=force, + invoke_separator=_invoke_separator_for_integration( + infra_integration, current, infra_key, infra_parsed + ), + ) + if os.name != "nt": + ensure_executable_scripts(project_root) + + # Phase 1: Install new files (overwrites existing; old-only files remain) + console.print(f"Upgrading integration: [cyan]{key}[/cyan]") + new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) + + try: + integration.setup( + project_root, + new_manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=raw_options, + ) + settings = _with_integration_setting( + current, + key, + integration, + script_type=selected_script, + raw_options=raw_options, + parsed_options=parsed_options, + ) + if installed_key == key: + try: + _refresh_shared_templates( + project_root, + invoke_separator=_invoke_separator_for_integration( + integration, {"integration_settings": settings}, key, parsed_options + ), + force=force, + ) + except (ValueError, OSError) as exc: + raise _SharedTemplateRefreshError( + f"Failed to refresh shared templates for '{key}': {exc}" + ) from exc + new_manifest.save() + _write_integration_json(project_root, installed_key, installed_keys, settings) + if installed_key == key: + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + except Exception as exc: + # Don't teardown — setup overwrites in-place, so teardown would + # delete files that were working before the upgrade. Just report. + console.print(f"[red]Error:[/red] Failed to upgrade integration: {exc}") + console.print("[yellow]The previous integration files may still be in place.[/yellow]") + raise typer.Exit(1) + + # Phase 2: Remove stale files from old manifest that are not in the new one + old_files = old_manifest.files + new_files = new_manifest.files + stale_keys = set(old_files) - set(new_files) + if stale_keys: + stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup") + stale_manifest._files = {k: old_files[k] for k in stale_keys} + stale_removed, _ = stale_manifest.uninstall(project_root, force=True) + if stale_removed: + console.print(f" Removed {len(stale_removed)} stale file(s) from previous install") + + name = (integration.config or {}).get("name", key) + console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") + + +# ===== Integration catalog discovery commands ===== +# +# These commands mirror the workflow catalog CLI shape: +# - `search` / `info` for discovery over the active catalog stack +# - `catalog list/add/remove` for managing catalog sources +# +# They deliberately do NOT add `integration add/remove/enable/disable/ +# set-priority`: integrations are single-active (install / uninstall / switch), +# not additive like extensions and presets. + + +@integration_app.command("search") +def integration_search( + query: Optional[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"), +): + """Search for integrations in the active catalog stack.""" + from .integrations import INTEGRATION_REGISTRY + from .integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + IntegrationValidationError, + ) + + project_root = _require_specify_project() + integration_config = _read_integration_json(project_root) + installed_key = integration_config.get("integration") + catalog = IntegrationCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag, author=author) + except IntegrationValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + console.print( + "\nTip: Check the configuration file path shown above for invalid catalog configuration " + "(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." + ) + raise typer.Exit(1) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): + console.print( + "\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid " + "catalog URL, or unset it to use the configured catalog files " + "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)." + ) + else: + console.print("\nTip: The catalog may be temporarily unavailable. Try again later.") + raise typer.Exit(1) + + if not results: + console.print("\n[yellow]No integrations found matching criteria[/yellow]") + if query or tag or author: + console.print("\nTry:") + console.print(" • Broader search terms") + console.print(" • Remove filters") + console.print(" • specify integration search (show all)") + return + + console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n") + for integ in sorted(results, key=lambda e: e.get("id", "")): + iid = integ.get("id", "?") + name = integ.get("name", iid) + version = integ.get("version", "?") + console.print(f"[bold]{name}[/bold] ({iid}) v{version}") + desc = integ.get("description", "") + if desc: + console.print(f" {desc}") + + console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}") + tags = integ.get("tags", []) + if isinstance(tags, list) and tags: + console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") + + cat_name = integ.get("_catalog_name", "") + install_allowed = integ.get("_install_allowed", True) + if cat_name: + if install_allowed: + console.print(f" [dim]Catalog:[/dim] {cat_name}") + else: + console.print( + f" [dim]Catalog:[/dim] {cat_name} " + "[yellow](discovery only — not installable)[/yellow]" + ) + + if iid == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + elif iid in INTEGRATION_REGISTRY: + console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}") + elif install_allowed: + console.print( + "\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs " + "can be installed with 'specify integration install'." + ) + else: + console.print( + f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'." + ) + console.print() + + +@integration_app.command("info") +def integration_info( + integration_id: str = typer.Argument(..., help="Integration ID"), +): + """Show catalog details for a single integration.""" + from .integrations import INTEGRATION_REGISTRY + from .integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + IntegrationValidationError, + ) + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + installed_key = _read_integration_json(project_root).get("integration") + + try: + info = catalog.get_integration_info(integration_id) + except IntegrationCatalogError as exc: + info = None + # Keep the live exception so the fallback branch below can give + # different guidance for local-config vs. network failures. + catalog_error: Optional[IntegrationCatalogError] = exc + else: + catalog_error = None + + if info: + name = info.get("name", integration_id) + version = info.get("version", "?") + console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}") + if info.get("description"): + console.print(f" {info['description']}") + console.print() + + console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}") + if info.get("license"): + console.print(f" [dim]License:[/dim] {info['license']}") + + tags = info.get("tags", []) + if isinstance(tags, list) and tags: + console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}") + + cat_name = info.get("_catalog_name", "") + install_allowed = info.get("_install_allowed", True) + if cat_name: + install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]" + console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}") + + if info.get("repository"): + console.print(f" [dim]Repository:[/dim] {info['repository']}") + + if integration_id == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + elif integration_id in INTEGRATION_REGISTRY: + console.print("\n [dim]Built-in integration (not currently active)[/dim]") + return + + if integration_id in INTEGRATION_REGISTRY: + integration = INTEGRATION_REGISTRY[integration_id] + cfg = integration.config or {} + name = cfg.get("name", integration_id) + console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})") + console.print(" [dim]Built-in integration (not listed in catalog)[/dim]") + if integration_id == installed_key: + console.print("\n [green]✓ Installed[/green] (currently active)") + if catalog_error: + console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}") + return + + if catalog_error: + console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}") + if isinstance(catalog_error, IntegrationValidationError): + console.print( + "\nCheck the configuration file path shown above " + "(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), " + "or use a built-in integration ID directly." + ) + elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip(): + console.print( + "\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, " + "or unset it to use the configured catalog files, or use a built-in integration ID directly." + ) + else: + console.print("\nTry again when online, or use a built-in integration ID directly.") + else: + console.print(f"[red]Error:[/red] Integration '{integration_id}' not found") + console.print("\nTry: specify integration search") + raise typer.Exit(1) + + +@integration_catalog_app.command("list") +def integration_catalog_list(): + """List configured integration catalog sources.""" + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() + + try: + if env_override: + project_configs = None + configs = catalog.get_catalog_configs() + else: + project_configs = catalog.get_project_catalog_configs() + configs = project_configs if project_configs is not None else catalog.get_catalog_configs() + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n") + if env_override: + console.print( + " SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files." + ) + console.print( + " Project/user catalog sources are not active while the env override is set.\n" + ) + console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n") + elif project_configs is None: + console.print(" No project-level catalog sources configured.\n") + console.print("[bold]Active catalog sources (non-removable here):[/bold]\n") + else: + console.print("[bold]Project catalog sources (removable):[/bold]\n") + + for i, cfg in enumerate(configs): + install_status = ( + "[green]install allowed[/green]" + if cfg.get("install_allowed") + else "[yellow]discovery only[/yellow]" + ) + raw_name = cfg.get("name") + display_name = str(raw_name).strip() if raw_name is not None else "" + if not display_name: + display_name = f"catalog-{i + 1}" + if env_override or project_configs is None: + console.print(f" - [bold]{display_name}[/bold] — {install_status}") + else: + console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}") + console.print(f" {cfg.get('url', '')}") + if cfg.get("description"): + console.print(f" [dim]{cfg['description']}[/dim]") + console.print() + + +@integration_catalog_app.command("add") +def integration_catalog_add( + url: str = typer.Argument( + ..., + help=( + "Catalog URL to add (HTTPS required, except http://localhost, " + "http://127.0.0.1, or http://[::1] for local testing)" + ), + ), + name: Optional[str] = typer.Option(None, "--name", help="Catalog name"), +): + """Add an integration catalog source to the project config.""" + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + + # Normalize once here so the success message reflects what was actually + # stored. ``IntegrationCatalog.add_catalog`` strips again defensively. + normalized_url = url.strip() + + try: + catalog.add_catalog(normalized_url, name) + except IntegrationCatalogError as exc: + # Covers both URL validation (base class) and config-file validation + # (IntegrationValidationError subclass). + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source added: {normalized_url}") + + +@integration_catalog_app.command("remove") +def integration_catalog_remove( + index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), +): + """Remove an integration catalog source by 0-based index.""" + from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + + try: + removed_name = catalog.remove_catalog(index) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") + + # ===== Preset Commands ===== @@ -2007,14 +3299,7 @@ def preset_list(): """List installed presets.""" from .presets import PresetManager - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() manager = PresetManager(project_root) installed = manager.list_installed() @@ -2039,7 +3324,7 @@ def preset_list(): @preset_app.command("add") def preset_add( - pack_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + 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)"), @@ -2053,14 +3338,7 @@ def preset_add( PresetCompatibilityError, ) - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() # Validate priority if priority < 1: console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") @@ -2097,7 +3375,9 @@ def preset_add( with tempfile.TemporaryDirectory() as tmpdir: zip_path = Path(tmpdir) / "preset.zip" try: - with urllib.request.urlopen(from_url, timeout=60) as response: + from specify_cli.authentication.http import open_url as _open_url + + with _open_url(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}") @@ -2107,29 +3387,51 @@ def preset_add( console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - elif pack_id: - catalog = PresetCatalog(project_root) - pack_info = catalog.get_pack_info(pack_id) + 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 '{pack_id}' not found in catalog") - raise typer.Exit(1) + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") + 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 '{pack_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) + # 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) - console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...") + 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) - try: - zip_path = catalog.download_pack(pack_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) + 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) @@ -2147,29 +3449,22 @@ def preset_add( @preset_app.command("remove") def preset_remove( - pack_id: str = typer.Argument(..., help="Preset ID to 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) - + project_root = _require_specify_project() manager = PresetManager(project_root) - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not 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) - if manager.remove(pack_id): - console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully") + 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 '{pack_id}'") + console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") raise typer.Exit(1) @@ -2182,14 +3477,7 @@ def preset_search( """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) - + project_root = _require_specify_project() catalog = PresetCatalog(project_root) try: @@ -2219,44 +3507,74 @@ def preset_resolve( """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) - + project_root = _require_specify_project() resolver = PresetResolver(project_root) - result = resolver.resolve_with_source(template_name) - - if result: - console.print(f" [bold]{template_name}[/bold]: {result['path']}") - console.print(f" [dim](from: {result['source']})[/dim]") + 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: - console.print(f" [yellow]{template_name}[/yellow]: not found") - console.print(" [dim]No template with this name exists in the resolution stack[/dim]") + # 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( - pack_id: str = typer.Argument(..., help="Preset ID to get info about"), + 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) - + project_root = _require_specify_project() # Check if installed locally first manager = PresetManager(project_root) - local_pack = manager.get_pack(pack_id) + local_pack = manager.get_pack(preset_id) if local_pack: console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") @@ -2278,7 +3596,7 @@ def preset_info( console.print(f" License: {license_val}") console.print("\n [green]Status: installed[/green]") # Get priority from registry - pack_metadata = manager.registry.get(pack_id) + 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() @@ -2287,15 +3605,15 @@ def preset_info( # Fall back to catalog catalog = PresetCatalog(project_root) try: - pack_info = catalog.get_pack_info(pack_id) + pack_info = catalog.get_pack_info(preset_id) except PresetError: pack_info = None if not pack_info: - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") raise typer.Exit(1) - console.print(f"\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\n") + console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n") console.print(f" ID: {pack_info['id']}") console.print(f" Version: {pack_info.get('version', '?')}") console.print(f" Description: {pack_info.get('description', '')}") @@ -2308,27 +3626,19 @@ def preset_info( 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 {pack_id}[/cyan]") + console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]") console.print() @preset_app.command("set-priority") def preset_set_priority( - pack_id: str = typer.Argument(help="Preset ID"), + 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) - + project_root = _require_specify_project() # Validate priority if priority < 1: console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") @@ -2337,14 +3647,14 @@ def preset_set_priority( manager = PresetManager(project_root) # Check if preset is installed - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not 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(pack_id) + metadata = manager.registry.get(preset_id) if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) from .extensions import normalize_priority @@ -2352,99 +3662,83 @@ def preset_set_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 '{pack_id}' already has priority {priority}[/yellow]") + console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") raise typer.Exit(0) old_priority = normalize_priority(raw_priority) # Update priority - manager.registry.update(pack_id, {"priority": priority}) + manager.registry.update(preset_id, {"priority": priority}) - console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_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( - pack_id: str = typer.Argument(help="Preset ID to 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) - + project_root = _require_specify_project() manager = PresetManager(project_root) # Check if preset is installed - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not 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(pack_id) + metadata = manager.registry.get(preset_id) if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) if metadata.get("enabled", True): - console.print(f"[yellow]Preset '{pack_id}' is already enabled[/yellow]") + console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") raise typer.Exit(0) # Enable the preset - manager.registry.update(pack_id, {"enabled": True}) + manager.registry.update(preset_id, {"enabled": True}) - console.print(f"[green]✓[/green] Preset '{pack_id}' enabled") + 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( - pack_id: str = typer.Argument(help="Preset ID to 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) - + project_root = _require_specify_project() manager = PresetManager(project_root) # Check if preset is installed - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not 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(pack_id) + metadata = manager.registry.get(preset_id) if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) if not metadata.get("enabled", True): - console.print(f"[yellow]Preset '{pack_id}' is already disabled[/yellow]") + console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") raise typer.Exit(0) # Disable the preset - manager.registry.update(pack_id, {"enabled": False}) + manager.registry.update(preset_id, {"enabled": False}) - console.print(f"[green]✓[/green] Preset '{pack_id}' disabled") + 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 {pack_id}") + console.print(f"To re-enable: specify preset enable {preset_id}") # ===== Preset Catalog Commands ===== @@ -2455,14 +3749,7 @@ 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) - + project_root = _require_specify_project() catalog = PresetCatalog(project_root) try: @@ -2495,7 +3782,7 @@ def preset_catalog_list(): except PresetValidationError: proj_loaded = False if proj_loaded: - console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]") else: try: user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None @@ -2524,13 +3811,8 @@ def preset_catalog_add( """Add a catalog to .specify/preset-catalogs.yml.""" from .presets import PresetCatalog, PresetValidationError - project_root = Path.cwd() - + project_root = _require_specify_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 URL tmp_catalog = PresetCatalog(project_root) @@ -2547,7 +3829,8 @@ def preset_catalog_add( 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}") + config_label = _display_project_path(project_root, config_path) + console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}") raise typer.Exit(1) else: config = {} @@ -2579,7 +3862,7 @@ def preset_catalog_add( 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)}") + console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}") @preset_catalog_app.command("remove") @@ -2587,13 +3870,8 @@ 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() - + project_root = _require_specify_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) config_path = specify_dir / "preset-catalogs.yml" if not config_path.exists(): @@ -2756,15 +4034,7 @@ def extension_list( """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) - + project_root = _require_specify_project() manager = ExtensionManager(project_root) installed = manager.list_installed() @@ -2797,14 +4067,7 @@ 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) - + project_root = _require_specify_project() catalog = ExtensionCatalog(project_root) try: @@ -2837,7 +4100,7 @@ def catalog_list(): except ValidationError: proj_loaded = False if proj_loaded: - console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]") else: try: user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None @@ -2866,13 +4129,8 @@ def catalog_add( """Add a catalog to .specify/extension-catalogs.yml.""" from .extensions import ExtensionCatalog, ValidationError - project_root = Path.cwd() - + project_root = _require_specify_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 URL tmp_catalog = ExtensionCatalog(project_root) @@ -2889,7 +4147,8 @@ def catalog_add( 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}") + config_label = _display_project_path(project_root, config_path) + console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}") raise typer.Exit(1) else: config = {} @@ -2921,7 +4180,7 @@ def catalog_add( 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)}") + console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}") @catalog_app.command("remove") @@ -2929,13 +4188,8 @@ def catalog_remove( name: str = typer.Argument(help="Catalog name to remove"), ): """Remove a catalog from .specify/extension-catalogs.yml.""" - project_root = Path.cwd() - + project_root = _require_specify_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) config_path = specify_dir / "extension-catalogs.yml" if not config_path.exists(): @@ -2975,17 +4229,9 @@ def extension_add( 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 - - 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) + from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND + project_root = _require_specify_project() # Validate priority if priority < 1: console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") @@ -3035,7 +4281,9 @@ def extension_add( zip_path = download_dir / f"{extension}-url-download.zip" try: - with urllib.request.urlopen(from_url, timeout=60) as response: + from specify_cli.authentication.http import open_url as _open_url + + with _open_url(from_url, timeout=60) as response: zip_data = response.read() zip_path.write_bytes(zip_data) @@ -3077,6 +4325,19 @@ def extension_add( 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") @@ -3106,6 +4367,10 @@ def extension_add( 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', '')}") @@ -3142,15 +4407,7 @@ def extension_remove( """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) - + project_root = _require_specify_project() manager = ExtensionManager(project_root) # Resolve extension ID from argument (handles ambiguous names) @@ -3159,15 +4416,28 @@ def extension_remove( # Get extension info for command and skill counts ext_manifest = manager.get_extension(extension_id) - cmd_count = len(ext_manifest.commands) if ext_manifest else 0 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} commands from AI agent") + console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent") if skill_count: console.print(f" • {skill_count} agent skill(s)") console.print(f" • Extension directory: .specify/extensions/{extension_id}/") @@ -3205,15 +4475,7 @@ def extension_search( """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) - + project_root = _require_specify_project() catalog = ExtensionCatalog(project_root) try: @@ -3289,15 +4551,7 @@ def extension_info( """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) - + project_root = _require_specify_project() catalog = ExtensionCatalog(project_root) manager = ExtensionManager(project_root) installed = manager.list_installed() @@ -3491,15 +4745,7 @@ def extension_update( 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) - + project_root = _require_specify_project() manager = ExtensionManager(project_root) catalog = ExtensionCatalog(project_root) speckit_version = get_speckit_version() @@ -3887,15 +5133,7 @@ def extension_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) - + project_root = _require_specify_project() manager = ExtensionManager(project_root) hook_executor = HookExecutor(project_root) @@ -3934,15 +5172,7 @@ def extension_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) - + project_root = _require_specify_project() manager = ExtensionManager(project_root) hook_executor = HookExecutor(project_root) @@ -3984,15 +5214,7 @@ def extension_set_priority( """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) - + project_root = _require_specify_project() # Validate priority if priority < 1: console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") @@ -4027,6 +5249,628 @@ def extension_set_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 = _require_specify_project() + 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 = _require_specify_project() + 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 = _require_specify_project() + 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 = _require_specify_project() + 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 = _require_specify_project() + 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 specify_cli.authentication.http import open_url as _open_url + + 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 _open_url(source, timeout=30) as resp: + 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 specify_cli.authentication.http import open_url as _open_url + + workflow_dir.mkdir(parents=True, exist_ok=True) + with _open_url(workflow_url, timeout=30) as response: + # 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 = _require_specify_project() + 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 = _require_specify_project() + 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 = _require_specify_project() + + # 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 = _require_specify_project() + 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 = _require_specify_project() + 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 = _require_specify_project() + 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() diff --git a/src/specify_cli/_github_http.py b/src/specify_cli/_github_http.py new file mode 100644 index 0000000000..ffa804dbb7 --- /dev/null +++ b/src/specify_cli/_github_http.py @@ -0,0 +1,93 @@ +"""Shared GitHub-authenticated HTTP helpers. + +Used by both ExtensionCatalog and PresetCatalog to attach +GITHUB_TOKEN / GH_TOKEN credentials to requests targeting +GitHub-hosted domains, while preventing token leakage to +third-party hosts on redirects. +""" + +import os +import urllib.request +from typing import Dict +from urllib.parse import urlparse + +# GitHub-owned hostnames that should receive the Authorization header. +# Includes codeload.github.com because GitHub archive URL downloads +# (e.g. /archive/refs/tags/.zip) redirect there and require auth +# for private repositories. +GITHUB_HOSTS = frozenset({ + "raw.githubusercontent.com", + "github.com", + "api.github.com", + "codeload.github.com", +}) + + +def build_github_request(url: str) -> urllib.request.Request: + """Build a urllib Request, adding a GitHub auth header when available. + + Reads GITHUB_TOKEN or GH_TOKEN from the environment and attaches an + ``Authorization: Bearer `` header when the target hostname is one + of the known GitHub-owned domains. Non-GitHub URLs are returned as plain + requests so credentials are never leaked to third-party hosts. + + Raises: + ValueError: If ``url`` is empty or whitespace-only. + ValueError: If ``url`` does not use the ``http`` or ``https`` scheme. + ValueError: If ``url`` does not include a hostname. + """ + headers: Dict[str, str] = {} + url = url.strip() + if not url: + raise ValueError("url must not be empty") + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"}: + raise ValueError(f"url must start with http:// or https://, got: {url!r}") + if not parsed.hostname: + raise ValueError(f"url must include a hostname, got: {url!r}") + github_token = (os.environ.get("GITHUB_TOKEN") or "").strip() + gh_token = (os.environ.get("GH_TOKEN") or "").strip() + token = github_token or gh_token or None + hostname = parsed.hostname.lower() + if token and hostname in GITHUB_HOSTS: + headers["Authorization"] = f"Bearer {token}" + return urllib.request.Request(url, headers=headers) + + +class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler): + """Redirect handler that drops the Authorization header when leaving GitHub. + + Prevents token leakage to CDNs or other third-party hosts that GitHub + may redirect to (e.g. S3 for release asset downloads, objects.githubusercontent.com). + Auth is preserved as long as the redirect target remains within GITHUB_HOSTS. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + original_auth = req.get_header("Authorization") + new_req = super().redirect_request(req, fp, code, msg, headers, newurl) + if new_req is not None: + hostname = (urlparse(newurl).hostname or "").lower() + if hostname in GITHUB_HOSTS: + if original_auth: + new_req.add_unredirected_header("Authorization", original_auth) + else: + new_req.headers.pop("Authorization", None) + new_req.unredirected_hdrs.pop("Authorization", None) + return new_req + + +def open_github_url(url: str, timeout: int = 10): + """Open a URL with GitHub auth, stripping the header on cross-host redirects. + + When the request carries an Authorization header, a custom redirect + handler drops that header if the redirect target is not a GitHub-owned + domain, preventing token leakage to CDNs or other third-party hosts + that GitHub may redirect to (e.g. S3 for release asset downloads). + """ + req = build_github_request(url) + + if not req.get_header("Authorization"): + return urllib.request.urlopen(req, timeout=timeout) + + opener = urllib.request.build_opener(_StripAuthOnRedirect) + return opener.open(req, timeout=timeout) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 4b869283cc..4d78d5ac41 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -6,24 +6,35 @@ command files into agent-specific directories in the correct format. """ -from pathlib import Path -from typing import Dict, List, Any - +import os import platform import re from copy import deepcopy +from pathlib import Path +from typing import Any, Dict, List, Optional + 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) + config = dict(integration.registrar_config) + # Propagate invoke_separator from the integration class when the + # registrar_config dict doesn't already declare it explicitly. + # SkillsIntegration subclasses (claude, codex, …) set + # invoke_separator="-" as a class attribute but omit it from + # registrar_config, so without this they would fall back to "." + # when register_commands() resolves __SPECKIT_COMMAND_*__ tokens. + if "invoke_separator" not in config: + config["invoke_separator"] = integration.invoke_separator + configs[key] = config return configs @@ -75,7 +86,7 @@ def parse_frontmatter(content: str) -> tuple[dict, str]: return {}, content frontmatter_str = content[3:end_marker].strip() - body = content[end_marker + 3:].strip() + body = content[end_marker + 3 :].strip() try: frontmatter = yaml.safe_load(frontmatter_str) or {} @@ -100,16 +111,18 @@ def render_frontmatter(fm: dict) -> str: if not fm: return "" - yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True) + 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` and `agent_scripts` keys (for example `../../scripts/`, - `../../templates/`, `../../memory/`, `scripts/`, `templates/`, and - `memory/`) to the `.specify/...` paths used in generated projects. + ``scripts`` key (for example ``../../scripts/``, + ``../../templates/``, ``../../memory/``, ``scripts/``, ``templates/``, and + ``memory/``) to the ``.specify/...`` paths used in generated projects. Args: frontmatter: Frontmatter dictionary @@ -119,11 +132,8 @@ def _adjust_script_paths(self, frontmatter: dict) -> dict: """ frontmatter = deepcopy(frontmatter) - for script_key in ("scripts", "agent_scripts"): - scripts = frontmatter.get(script_key) - if not isinstance(scripts, dict): - continue - + 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) @@ -146,16 +156,16 @@ def rewrite_project_relative_paths(text: str) -> str: # ".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) + text = re.sub( + r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text + ) - return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/") + 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 + self, frontmatter: dict, body: str, source_id: str, context_note: str = None ) -> str: """Render command in Markdown format. @@ -172,12 +182,7 @@ def render_markdown_command( 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: + def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> str: """Render command in TOML format. Args: @@ -192,7 +197,7 @@ def render_toml_command( if "description" in frontmatter: toml_lines.append( - f'description = {self._render_basic_toml_string(frontmatter["description"])}' + f"description = {self._render_basic_toml_string(frontmatter['description'])}" ) toml_lines.append("") @@ -226,6 +231,41 @@ def _render_basic_toml_string(value: str) -> str: ) 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, @@ -251,10 +291,15 @@ def render_skill_command( if not isinstance(frontmatter, dict): frontmatter = {} - if agent_name in {"codex", "kimi"}: - body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) + 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}") + description = frontmatter.get( + "description", f"Spec-kit workflow command: {skill_name}" + ) skill_frontmatter = self.build_skill_frontmatter( agent_name, skill_name, @@ -280,15 +325,12 @@ def build_skill_frontmatter( "source": source, }, } - if agent_name == "claude": - # Claude skills should be user-invocable (accessible via /command) - # and only run when explicitly invoked (not auto-triggered by the model). - skill_frontmatter["user-invocable"] = True - skill_frontmatter["disable-model-invocation"] = True return skill_frontmatter @staticmethod - def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str: + 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 @@ -299,11 +341,8 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr frontmatter = {} scripts = frontmatter.get("scripts", {}) or {} - agent_scripts = frontmatter.get("agent_scripts", {}) or {} if not isinstance(scripts, dict): scripts = {} - if not isinstance(agent_scripts, dict): - agent_scripts = {} init_opts = load_init_options(project_root) if not isinstance(init_opts, dict): @@ -312,20 +351,19 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr 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" + 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 or default_variant in agent_scripts: + if default_variant in scripts: fallback_order.append(default_variant) - if secondary_variant in scripts or secondary_variant in agent_scripts: + if secondary_variant in scripts: fallback_order.append(secondary_variant) for key in scripts: if key not in fallback_order: fallback_order.append(key) - for key in agent_scripts: - if key not in fallback_order: - fallback_order.append(key) script_variant = fallback_order[0] if fallback_order else None @@ -334,15 +372,17 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr script_command = script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{SCRIPT}", script_command) - agent_script_command = agent_scripts.get(script_variant) if script_variant else None - if agent_script_command: - agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS") - body = body.replace("{AGENT_SCRIPT}", agent_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: + def _convert_argument_placeholder( + self, content: str, from_placeholder: str, to_placeholder: str + ) -> str: """Convert argument placeholder format. Args: @@ -356,18 +396,40 @@ def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_ return content.replace(from_placeholder, to_placeholder) @staticmethod - def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str: + 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[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, @@ -375,7 +437,7 @@ def register_commands( source_id: str, source_dir: Path, project_root: Path, - context_note: str = None + context_note: str = None, ) -> List[str]: """Register commands for a specific agent. @@ -414,32 +476,81 @@ def register_commands( 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"): - frontmatter["name"] = cmd_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"] ) + # Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator. + # The separator is sourced from agent_config (populated by _build_agent_configs, + # which propagates each integration's invoke_separator class attribute). + # Deferred import of IntegrationBase avoids a circular import at module load + # (base.py itself imports CommandRegistrar lazily). + from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415 + + _sep = agent_config.get("invoke_separator", ".") + body = IntegrationBase.resolve_command_refs(body, _sep) + 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 + agent_name, + output_name, + frontmatter, + body, + source_id, + cmd_file, + project_root, ) elif agent_config["format"] == "markdown": - output = self.render_markdown_command(frontmatter, body, source_id, context_note) + 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") @@ -449,32 +560,63 @@ def register_commands( registered.append(cmd_name) for alias in cmd_info.get("aliases", []): - alias_output_name = self._compute_output_name(agent_name, alias, agent_config) + 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) - alias_frontmatter["name"] = alias + # 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 + 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) + 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) + 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']}") + 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 + agent_name, + alias_output_name, + frontmatter, + body, + source_id, + cmd_file, + project_root, ) - alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}" + 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": @@ -494,6 +636,7 @@ def write_copilot_prompt(project_root: Path, cmd_name: str) -> None: 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( @@ -502,7 +645,7 @@ def register_commands_for_all_agents( source_id: str, source_dir: Path, project_root: Path, - context_note: str = None + context_note: str = None, ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. @@ -525,8 +668,12 @@ def register_commands_for_all_agents( if agent_dir.exists(): try: registered = self.register_commands( - agent_name, commands, source_id, source_dir, project_root, - context_note=context_note + agent_name, + commands, + source_id, + source_dir, + project_root, + context_note=context_note, ) if registered: results[agent_name] = registered @@ -535,10 +682,54 @@ def register_commands_for_all_agents( return results - def unregister_commands( + def register_commands_for_non_skill_agents( self, - registered_commands: Dict[str, List[str]], - project_root: Path + 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. @@ -555,13 +746,26 @@ def unregister_commands( 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) + 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" + prompt_file = ( + project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + ) if prompt_file.exists(): prompt_file.unlink() diff --git a/src/specify_cli/authentication/__init__.py b/src/specify_cli/authentication/__init__.py new file mode 100644 index 0000000000..b4963af76b --- /dev/null +++ b/src/specify_cli/authentication/__init__.py @@ -0,0 +1,50 @@ +"""Authentication provider registry for multi-platform support. + +Credentials are **opt-in only**. No authentication headers are sent unless +the user creates ``~/.specify/auth.json`` mapping hosts to providers. +Provider classes define *how* to authenticate (Bearer, Basic-PAT, etc.) +while the config file defines *where* and *with what credentials*. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .base import AuthProvider + +# Maps provider key → AuthProvider class instance. +AUTH_REGISTRY: dict[str, AuthProvider] = {} + + +def _register(provider: AuthProvider) -> None: + """Register a provider instance in the global registry. + + Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates. + """ + key = provider.key + if not key: + raise ValueError("Cannot register provider with an empty key.") + if key in AUTH_REGISTRY: + raise KeyError(f"Provider with key {key!r} is already registered.") + AUTH_REGISTRY[key] = provider + + +def get_provider(key: str) -> AuthProvider | None: + """Return the provider for *key*, or ``None`` if not registered.""" + return AUTH_REGISTRY.get(key) + + +# -- Register built-in providers ----------------------------------------- + + +def _register_builtins() -> None: + """Register all built-in authentication providers (alphabetical).""" + from .azure_devops import AzureDevOpsAuth + from .github import GitHubAuth + + _register(AzureDevOpsAuth()) + _register(GitHubAuth()) + + +_register_builtins() diff --git a/src/specify_cli/authentication/azure_devops.py b/src/specify_cli/authentication/azure_devops.py new file mode 100644 index 0000000000..5d71a1957b --- /dev/null +++ b/src/specify_cli/authentication/azure_devops.py @@ -0,0 +1,117 @@ +"""Azure DevOps authentication provider.""" + +from __future__ import annotations + +import base64 +import json as _json +import os +import subprocess +from typing import TYPE_CHECKING + +from .base import AuthProvider + +if TYPE_CHECKING: + from .config import AuthConfigEntry + +# Azure DevOps resource ID for OAuth / Azure AD token acquisition. +_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798" + + +class AzureDevOpsAuth(AuthProvider): + """Azure DevOps authentication provider. + + Supports four auth schemes: + + * ``basic-pat`` — PAT with empty username, Base64-encoded as ``:`` + * ``bearer`` — pre-acquired OAuth / Azure AD token + * ``azure-cli`` — acquires a token via ``az account get-access-token`` + * ``azure-ad`` — acquires a token via OAuth2 client credentials flow + """ + + key = "azure-devops" + supported_auth_schemes = ("basic-pat", "bearer", "azure-cli", "azure-ad") + + def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: + """Build the ``Authorization`` header for the given scheme.""" + if auth_scheme == "basic-pat": + encoded = base64.b64encode(f":{token}".encode("ascii")).decode("ascii") + return {"Authorization": f"Basic {encoded}"} + if auth_scheme in ("bearer", "azure-cli", "azure-ad"): + return {"Authorization": f"Bearer {token}"} + raise ValueError( + f"AzureDevOpsAuth does not support auth scheme {auth_scheme!r}" + ) + + def resolve_token(self, entry: AuthConfigEntry) -> str | None: + """Resolve token, with special handling for azure-cli and azure-ad.""" + if entry.auth == "azure-cli": + return self._acquire_via_az_cli() + if entry.auth == "azure-ad": + return self._acquire_via_client_credentials(entry) + return super().resolve_token(entry) + + # -- Token acquisition ------------------------------------------------ + + @staticmethod + def _acquire_via_az_cli() -> str | None: + """Run ``az account get-access-token`` and return the access token.""" + try: + result = subprocess.run( # noqa: S603, S607 + [ + "az", + "account", + "get-access-token", + "--resource", + _ADO_RESOURCE_ID, + "--output", + "json", + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 0: + return None + payload = _json.loads(result.stdout) + token = payload.get("accessToken", "").strip() + return token or None + except (OSError, subprocess.TimeoutExpired, _json.JSONDecodeError, KeyError): + return None + + @staticmethod + def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None: + """Acquire a token via OAuth2 client credentials flow.""" + import urllib.error + import urllib.request + + if not entry.tenant_id or not entry.client_id or not entry.client_secret_env: + return None + client_secret = os.environ.get(entry.client_secret_env, "").strip() + if not client_secret: + return None + + url = ( + f"https://login.microsoftonline.com/{entry.tenant_id}" + "/oauth2/v2.0/token" + ) + from urllib.parse import urlencode + body = urlencode({ + "grant_type": "client_credentials", + "client_id": entry.client_id, + "client_secret": client_secret, + "scope": f"{_ADO_RESOURCE_ID}/.default", + }).encode("utf-8") + + req = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 + payload = _json.loads(resp.read().decode("utf-8")) + token = payload.get("access_token", "").strip() + return token or None + except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError): + return None diff --git a/src/specify_cli/authentication/base.py b/src/specify_cli/authentication/base.py new file mode 100644 index 0000000000..d6e0f4b118 --- /dev/null +++ b/src/specify_cli/authentication/base.py @@ -0,0 +1,57 @@ +"""Abstract base class for authentication providers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .config import AuthConfigEntry + + +class AuthProvider(ABC): + """Abstract base class every authentication provider must implement. + + Subclasses must set: + + * ``key`` — unique provider identifier (e.g. ``"github"``, ``"azure-devops"``) + * ``supported_auth_schemes`` — tuple of auth scheme strings this provider handles + + And implement: + + * ``auth_headers(token, auth_scheme)`` — build headers from a resolved token + * ``resolve_token(entry)`` — obtain the token for a config entry + """ + + key: str = "" + """Unique provider identifier.""" + + supported_auth_schemes: tuple[str, ...] = () + """Auth schemes this provider supports (e.g. ``("bearer",)``).""" + + @abstractmethod + def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: + """Build authentication headers for *token* using *auth_scheme*. + + Must return a dict with at least an ``Authorization`` key. + """ + + def resolve_token(self, entry: AuthConfigEntry) -> str | None: + """Resolve the token for *entry*. + + Default implementation reads from ``entry.token`` directly + or from the environment variable named by ``entry.token_env``. + Override for schemes that acquire tokens dynamically + (e.g. ``azure-cli``, ``azure-ad``). + """ + import os + + if entry.token: + return entry.token.strip() or None + if entry.token_env: + val = os.environ.get(entry.token_env) + if val is not None: + val = val.strip() + if val: + return val + return None diff --git a/src/specify_cli/authentication/config.py b/src/specify_cli/authentication/config.py new file mode 100644 index 0000000000..968cadc466 --- /dev/null +++ b/src/specify_cli/authentication/config.py @@ -0,0 +1,209 @@ +"""Authentication configuration loader. + +Reads ``~/.specify/auth.json`` to determine which hosts receive credentials +and which provider/auth-scheme to use. No credentials are sent without +an explicit opt-in via this file. +""" + +from __future__ import annotations + +import json +import os +import stat +from dataclasses import dataclass +from fnmatch import fnmatch +from pathlib import Path +from urllib.parse import urlparse + + +@dataclass(frozen=True) +class AuthConfigEntry: + """A single provider entry from ``auth.json``.""" + + hosts: tuple[str, ...] + provider: str + auth: str + token: str | None = None + token_env: str | None = None + # Azure AD service-principal fields + tenant_id: str | None = None + client_id: str | None = None + client_secret_env: str | None = None + + +def _default_config_path() -> Path: + """Return ``~/.specify/auth.json``.""" + return Path.home() / ".specify" / "auth.json" + + +def _is_valid_host_pattern(pattern: str) -> bool: + """Return True for safe host patterns: exact hostnames or ``*.suffix`` only. + + Rejects patterns like ``*github.com`` (which would match + ``github.com.evil.com``) or multi-wildcard forms. Only these two + forms are accepted: + + * ``example.com`` — exact hostname + * ``*.example.com`` — leading ``*.`` wildcard; matches subdomains + such as ``myorg.example.com`` but not ``example.com`` itself + """ + if "*" not in pattern: + return True # exact hostname — already validated as non-empty + # Only *.suffix is allowed; no other wildcard positions + return pattern.startswith("*.") and "*" not in pattern[2:] + + +def load_auth_config( + path: Path | None = None, +) -> list[AuthConfigEntry]: + """Load and validate ``auth.json``, returning configured entries. + + Returns an empty list when the file does not exist — this means + all HTTP requests will be unauthenticated (opt-in model). + + Raises ``ValueError`` on schema violations. Callers that want + misconfigurations to fail fast can allow this exception to + propagate; higher-level HTTP helpers may instead catch it, + warn, and continue with unauthenticated requests. + """ + config_path = path or _default_config_path() + + if not config_path.is_file(): + return [] + + # Warn (but don't fail) if the file is world-readable (POSIX only). + if os.name != "nt": + try: + mode = config_path.stat().st_mode + if mode & (stat.S_IRGRP | stat.S_IROTH): + import warnings + + warnings.warn( + f"{config_path} is readable by group/others. " + "Consider restricting with: chmod 600 " + f"{config_path}", + UserWarning, + stacklevel=2, + ) + except OSError: + pass # stat failed — skip permission check + + raw = json.loads(config_path.read_text(encoding="utf-8")) + + if not isinstance(raw, dict): + raise ValueError(f"auth.json must be a JSON object, got {type(raw).__name__}") + + providers_raw = raw.get("providers") + if not isinstance(providers_raw, list): + raise ValueError("auth.json must contain a 'providers' array") + + entries: list[AuthConfigEntry] = [] + for i, entry_raw in enumerate(providers_raw): + if not isinstance(entry_raw, dict): + raise ValueError(f"providers[{i}]: must be a JSON object") + + hosts = entry_raw.get("hosts") + if not isinstance(hosts, list) or not hosts: + raise ValueError(f"providers[{i}]: 'hosts' must be a non-empty array") + if not all(isinstance(h, str) and h.strip() for h in hosts): + raise ValueError(f"providers[{i}]: each host must be a non-empty string") + # Normalize hosts: strip whitespace and lowercase + hosts = [h.strip().lower() for h in hosts] + # Reject dangerous wildcard forms (e.g. *github.com matches github.com.evil.com) + for h in hosts: + if not _is_valid_host_pattern(h): + raise ValueError( + f"providers[{i}]: invalid host pattern {h!r}. " + "Only exact hostnames or '*.suffix' forms are allowed " + "(e.g. 'github.com' or '*.visualstudio.com')." + ) + + provider = entry_raw.get("provider", "") + if not isinstance(provider, str) or not provider: + raise ValueError(f"providers[{i}]: 'provider' must be a non-empty string") + + auth = entry_raw.get("auth", "") + if not isinstance(auth, str) or not auth: + raise ValueError(f"providers[{i}]: 'auth' must be a non-empty string") + + token = entry_raw.get("token") + token_env = entry_raw.get("token_env") + + # Validate token/token_env types + if token is not None and (not isinstance(token, str) or not token.strip()): + raise ValueError(f"providers[{i}]: 'token' must be a non-empty string") + if token_env is not None and (not isinstance(token_env, str) or not token_env.strip()): + raise ValueError(f"providers[{i}]: 'token_env' must be a non-empty string") + + # Validate provider+scheme compatibility + from . import get_provider as _get_provider + _prov = _get_provider(provider) + if _prov is None: + from . import AUTH_REGISTRY + raise ValueError( + f"providers[{i}]: unknown provider {provider!r}; " + f"registered: {sorted(AUTH_REGISTRY.keys())}" + ) + if auth not in _prov.supported_auth_schemes: + raise ValueError( + f"providers[{i}]: provider {provider!r} does not support " + f"auth scheme {auth!r}; supported: {list(_prov.supported_auth_schemes)}" + ) + + # Validate token source based on auth scheme + if auth in ("bearer", "basic-pat"): + if not token and not token_env: + raise ValueError( + f"providers[{i}]: auth={auth!r} requires 'token' or 'token_env'" + ) + elif auth == "azure-ad": + tenant_id = entry_raw.get("tenant_id") + client_id = entry_raw.get("client_id") + client_secret_env = entry_raw.get("client_secret_env") + if not all([tenant_id, client_id, client_secret_env]): + raise ValueError( + f"providers[{i}]: auth='azure-ad' requires " + "'tenant_id', 'client_id', and 'client_secret_env'" + ) + for field_name, field_val in [ + ("tenant_id", tenant_id), + ("client_id", client_id), + ("client_secret_env", client_secret_env), + ]: + if not isinstance(field_val, str) or not field_val.strip(): + raise ValueError( + f"providers[{i}]: '{field_name}' must be a non-empty string" + ) + # azure-cli needs no extra fields + + entries.append( + AuthConfigEntry( + hosts=tuple(hosts), + provider=provider, + auth=auth, + token=token, + token_env=token_env, + tenant_id=entry_raw.get("tenant_id"), + client_id=entry_raw.get("client_id"), + client_secret_env=entry_raw.get("client_secret_env"), + ) + ) + + return entries + + +def find_entries_for_url( + url: str, entries: list[AuthConfigEntry] +) -> list[AuthConfigEntry]: + """Return entries whose ``hosts`` match the hostname of *url*.""" + hostname = (urlparse(url).hostname or "").lower() + if not hostname: + return [] + return [ + e + for e in entries + if any( + pattern == hostname or fnmatch(hostname, pattern) + for pattern in e.hosts + ) + ] diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py new file mode 100644 index 0000000000..3797d82926 --- /dev/null +++ b/src/specify_cli/authentication/github.py @@ -0,0 +1,24 @@ +"""GitHub authentication provider.""" + +from __future__ import annotations + +from .base import AuthProvider + + +class GitHubAuth(AuthProvider): + """GitHub authentication provider. + + Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs, + OAuth tokens, and GitHub App installation tokens. + """ + + key = "github" + supported_auth_schemes = ("bearer",) + + def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: + """Return ``Authorization: Bearer ``.""" + if auth_scheme != "bearer": + raise ValueError( + f"GitHubAuth does not support auth scheme {auth_scheme!r}" + ) + return {"Authorization": f"Bearer {token}"} diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py new file mode 100644 index 0000000000..d6402e5c3e --- /dev/null +++ b/src/specify_cli/authentication/http.py @@ -0,0 +1,149 @@ +"""Authenticated HTTP helpers driven by ``~/.specify/auth.json``. + +No credentials are sent unless the user has created ``auth.json``. +For each outbound URL the helper matches the hostname against +configured entries, resolves the token via the appropriate provider +class, and attaches auth headers. Redirect safety is enforced: +the ``Authorization`` header is stripped when a redirect leaves the +entry's declared hosts. On 401/403 the next matching entry is tried, +then unauthenticated. +""" + +from __future__ import annotations + +import urllib.error +import urllib.request +from fnmatch import fnmatch +from urllib.parse import urlparse + +from . import get_provider +from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config + + +_config_override: list[AuthConfigEntry] | None = None +_config_cache: list[AuthConfigEntry] | None = None # None = not yet loaded + + +def _load_config() -> list[AuthConfigEntry]: + """Load auth config, using override if set (for testing). + + The result is cached per-process so ``auth.json`` is read at most once, + and any warning about a malformed file fires only once. + """ + global _config_cache + if _config_override is not None: + return _config_override + if _config_cache is not None: + return _config_cache + try: + _config_cache = load_auth_config() + except (ValueError, OSError) as exc: + import warnings + config_path = _default_config_path() + warnings.warn( + f"Failed to load {config_path}: {exc}. " + "All requests will be unauthenticated.", + UserWarning, + stacklevel=2, + ) + _config_cache = [] + return _config_cache + + +def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool: + """Return True if *hostname* matches any pattern in *hosts*.""" + hostname = hostname.lower() + return any(p == hostname or fnmatch(hostname, p) for p in hosts) + + +class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler): + """Drop ``Authorization`` when a redirect leaves the entry's declared hosts.""" + + def __init__(self, hosts: tuple[str, ...]) -> None: + super().__init__() + self._hosts = hosts + + def redirect_request(self, req, fp, code, msg, headers, newurl): + original_auth = ( + req.get_header("Authorization") + or req.unredirected_hdrs.get("Authorization") + ) + new_req = super().redirect_request(req, fp, code, msg, headers, newurl) + if new_req is not None: + hostname = (urlparse(newurl).hostname or "").lower() + if _hostname_in_hosts(hostname, self._hosts): + if original_auth: + new_req.add_unredirected_header("Authorization", original_auth) + else: + new_req.headers.pop("Authorization", None) + new_req.unredirected_hdrs.pop("Authorization", None) + return new_req + + +def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urllib.request.Request: + """Build a :class:`~urllib.request.Request`, attaching auth when config matches. + + Uses the first matching entry from ``auth.json`` whose token resolves. + Returns a plain request when no entry matches or the file doesn't exist. + """ + headers: dict[str, str] = {} + if extra_headers: + # Strip Authorization from extra_headers to prevent bypass + headers.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"}) + # Auth headers applied last — cannot be overridden by extra_headers + entries = find_entries_for_url(url, _load_config()) + for entry in entries: + provider = get_provider(entry.provider) + if provider is None: + continue + token = provider.resolve_token(entry) + if token: + headers.update(provider.auth_headers(token, entry.auth)) + break + return urllib.request.Request(url, headers=headers) + + +def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None): + """Open *url* with config-driven auth, redirect stripping, and fallthrough. + + 1. Find ``auth.json`` entries whose hosts match the URL. + 2. For each entry, resolve the token and try the request. + 3. On 401/403 move to the next matching entry. + 4. After all entries exhausted (or none matched), try unauthenticated. + 5. Non-auth errors (404, 500, network) raise immediately. + + *extra_headers* (e.g. ``Accept``) are merged into every attempt. + """ + entries = find_entries_for_url(url, _load_config()) + + def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request: + merged = {} + if extra_headers: + # Strip Authorization from extra_headers to prevent bypass + merged.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"}) + # Auth headers applied last — cannot be overridden by extra_headers + merged.update(auth_headers) + return urllib.request.Request(url, headers=merged) + + # Try each matching entry + for entry in entries: + provider = get_provider(entry.provider) + if provider is None: + continue + token = provider.resolve_token(entry) + if not token: + continue + + req = _make_req(provider.auth_headers(token, entry.auth)) + opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts)) + try: + return opener.open(req, timeout=timeout) + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + exc.close() + continue # try next entry + raise + + # No entry worked (or none matched) — unauthenticated fallback + req = _make_req({}) + return urllib.request.urlopen(req, timeout=timeout) # noqa: S310 diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 6d7b7c1199..944ee4a06d 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -38,6 +38,8 @@ }) 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. @@ -130,18 +132,30 @@ 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() def _load_yaml(self, path: Path) -> dict: """Load YAML file safely.""" try: - with open(path, 'r') as f: - return yaml.safe_load(f) or {} + with open(path, 'r', encoding='utf-8') as f: + 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}") + except UnicodeDecodeError as e: + raise ValidationError( + f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})" + ) + except OSError as e: + raise ValidationError(f"Could not read manifest {path}: {e}") + 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.""" @@ -215,18 +229,99 @@ def _validate(self): f"Hook '{hook_name}' missing required 'command' field" ) - # Validate commands (if present) + # 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 EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None: + 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: """Get extension ID.""" @@ -523,10 +618,11 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st """Collect command and alias names declared by a manifest. Performs install-time validation for extension-specific constraints: - - commands and aliases must use the canonical `speckit.{extension}.{command}` shape - - commands and aliases must use this extension's namespace + - 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 @@ -563,23 +659,26 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st f"{kind.capitalize()} for command '{primary_name}' must be a string" ) - match = EXTENSION_COMMAND_NAME_PATTERN.match(name) - if match is None: - raise ValidationError( - f"Invalid {kind} '{name}': " - "must follow pattern 'speckit.{extension}.{command}'" - ) + # 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}'" - ) + 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 namespace in CORE_COMMAND_NAMES: + raise ValidationError( + f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'" + ) if name in declared_names: raise ValidationError( @@ -762,6 +861,7 @@ def _register_extension_skills( from . import load_init_options from .agents import CommandRegistrar + from .integrations import get_integration import yaml written: List[str] = [] @@ -772,6 +872,7 @@ def _register_extension_skills( 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"] @@ -851,35 +952,50 @@ def _register_extension_skills( 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: + def _unregister_extension_skills( + self, + skill_names: List[str], + extension_id: str, + skills_dir: Optional[Path] = None, + ) -> 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. + If *skills_dir* is not provided and ``_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. + skills_dir: Optional explicit skills directory to use instead + of resolving via ``_get_skills_dir()``. Useful when the + caller needs to target a specific agent's skills directory + regardless of the currently-active agent in init-options. """ if not skill_names: return - skills_dir = self._get_skills_dir() + if skills_dir is None: + skills_dir = self._get_skills_dir() if skills_dir: # Fast path: we know the exact skills directory @@ -1003,7 +1119,7 @@ def check_compatibility( raise CompatibilityError( f"Extension requires spec-kit {required}, " f"but {speckit_version} is installed.\n" - f"Upgrade spec-kit with: uv tool install specify-cli --force" + f"Upgrade spec-kit with: {REINSTALL_COMMAND}" ) except InvalidSpecifier: raise CompatibilityError(f"Invalid version specifier: {required}") @@ -1227,6 +1343,156 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: return True + @staticmethod + def _valid_name_list(value: Any) -> List[str]: + """Return string entries from a registry list, ignoring corrupt values.""" + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str)] + + def unregister_agent_artifacts(self, agent_name: str) -> None: + """Remove extension files registered for a specific agent. + + Extension command files are tracked per agent in ``registered_commands``. + Extension skills are scoped to the provided *agent_name*; they are removed + from that agent's skills directory (resolved via its integration config) + and the registry field is cleared. + + Skips cleanup when *agent_name* is not a supported agent to avoid + losing registry entries while leaving orphaned files on disk. + """ + if not agent_name: + return + + registrar = CommandRegistrar() + if agent_name not in registrar.AGENT_CONFIGS: + return + + # Resolve the skills directory for the specific agent so cleanup is + # agent-scoped and does not depend on the currently-active agent in + # init-options. Use the same helper that extension install uses. + from . import _get_skills_dir as resolve_skills_dir + + agent_skills_dir = resolve_skills_dir(self.project_root, agent_name) + + for ext_id, metadata in self.registry.list().items(): + updates: Dict[str, Any] = {} + + registered_commands = metadata.get("registered_commands", {}) + if isinstance(registered_commands, dict) and agent_name in registered_commands: + command_names = self._valid_name_list(registered_commands.get(agent_name)) + if command_names: + registrar.unregister_commands({agent_name: command_names}, self.project_root) + + new_registered = copy.deepcopy(registered_commands) + new_registered.pop(agent_name, None) + updates["registered_commands"] = new_registered + + registered_skills = self._valid_name_list(metadata.get("registered_skills", [])) + if registered_skills: + # Only pass the resolved skills_dir when it actually exists. + # Otherwise let _unregister_extension_skills fall back to + # scanning all known agent skills directories, which is useful + # for cleaning up stale entries created by earlier installs. + skills_dir = agent_skills_dir if agent_skills_dir.is_dir() else None + self._unregister_extension_skills( + registered_skills, ext_id, skills_dir=skills_dir + ) + + # Only reconcile registry state when cleanup was scoped to a + # specific existing directory. When skills_dir is None, + # _unregister_extension_skills falls back to scanning multiple + # candidate directories, so agent_skills_dir cannot be used to + # infer what was removed. When skills_dir is set, + # _unregister_extension_skills may intentionally skip deletion + # when ownership cannot be verified (e.g., corrupted/missing + # SKILL.md or mismatching metadata.source). Only drop registry + # entries for skill directories that were actually removed so + # future cleanup attempts can still find skipped ones. + if skills_dir is not None: + remaining_skills = [ + skill_name + for skill_name in registered_skills + if (skills_dir / skill_name).is_dir() + ] + if remaining_skills != registered_skills: + updates["registered_skills"] = remaining_skills + + if updates: + self.registry.update(ext_id, updates) + + def register_enabled_extensions_for_agent(self, agent_name: str) -> None: + """Register installed, enabled extensions for ``agent_name``. + + This is intended to be called after switching integrations. Command + registration is scoped to the explicit ``agent_name`` argument, but some + behavior still depends on the current init-options state (for example, + skills-mode handling uses the active ``ai`` / ``ai_skills`` settings). + + Callers should therefore pass the agent that has just been made active + in init-options; in normal use, ``agent_name`` is expected to match the + current ``ai`` value. This mirrors extension install behavior while + avoiding stale default-mode command directories when that active agent + is running in skills mode (notably Copilot ``--skills``). + """ + if not agent_name: + return + + from . import load_init_options + + registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + init_options = load_init_options(self.project_root) + if not isinstance(init_options, dict): + init_options = {} + + active_agent = init_options.get("ai") + skills_mode_active = ( + active_agent == agent_name + and bool(init_options.get("ai_skills")) + and bool(agent_config) + and agent_config.get("extension") != "/SKILL.md" + ) + + for ext_id, metadata in self.registry.list().items(): + if not metadata.get("enabled", True): + continue + + manifest = self.get_extension(ext_id) + if manifest is None: + continue + + ext_dir = self.extensions_dir / ext_id + updates: Dict[str, Any] = {} + + if agent_config and not skills_mode_active: + registered = registrar.register_commands_for_agent( + agent_name, manifest, ext_dir, self.project_root + ) + registered_commands = metadata.get("registered_commands", {}) + if not isinstance(registered_commands, dict): + registered_commands = {} + new_registered = copy.deepcopy(registered_commands) + if registered: + new_registered[agent_name] = registered + else: + # Registration returned empty list (e.g., corrupted + # manifest pointing at missing command files). Clear + # stale entry so later cleanup doesn't try to remove + # files that were never written. + new_registered.pop(agent_name, None) + if new_registered != registered_commands: + updates["registered_commands"] = new_registered + + registered_skills = self._register_extension_skills(manifest, ext_dir) + if registered_skills: + existing_skills = self._valid_name_list(metadata.get("registered_skills", [])) + merged_skills = list(dict.fromkeys(existing_skills + registered_skills)) + updates["registered_skills"] = merged_skills + + if updates: + self.registry.update(ext_id, updates) + def list_installed(self) -> List[Dict[str, Any]]: """List all installed extensions with metadata. @@ -1440,6 +1706,22 @@ def _validate_catalog_url(self, url: str) -> None: if not parsed.netloc: raise ValidationError("Catalog URL must be a valid URL with a host.") + def _make_request(self, url: str): + """Build a urllib Request, adding auth headers when a provider matches. + + Delegates to :func:`specify_cli.authentication.http.build_request`. + """ + from specify_cli.authentication.http import build_request + return build_request(url) + + def _open_url(self, url: str, timeout: int = 10): + """Open a URL with provider-based auth, trying each configured provider. + + Delegates to :func:`specify_cli.authentication.http.open_url`. + """ + from specify_cli.authentication.http import open_url + return open_url(url, timeout) + def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]: """Load catalog stack configuration from a YAML file. @@ -1596,7 +1878,6 @@ def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False 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) @@ -1630,7 +1911,7 @@ def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False # Fetch from network try: - with urllib.request.urlopen(entry.url, timeout=10) as response: + with self._open_url(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: @@ -1744,10 +2025,9 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: catalog_url = self.get_catalog_url() try: - import urllib.request import urllib.error - with urllib.request.urlopen(catalog_url, timeout=10) as response: + with self._open_url(catalog_url, timeout=10) as response: catalog_data = json.loads(response.read()) # Validate catalog structure @@ -1858,7 +2138,6 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non Raises: ExtensionError: If extension not found or download fails """ - import urllib.request import urllib.error # Get extension info from catalog @@ -1866,6 +2145,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") @@ -1890,7 +2177,7 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non # Download the ZIP file try: - with urllib.request.urlopen(download_url, timeout=60) as response: + with self._open_url(download_url, timeout=60) as response: zip_data = response.read() zip_path.write_bytes(zip_data) @@ -2166,6 +2453,7 @@ def _render_hook_invocation(self, command: Any) -> str: 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: @@ -2174,6 +2462,8 @@ def _render_hook_invocation(self, command: Any) -> str: 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}" diff --git a/src/specify_cli/integration_runtime.py b/src/specify_cli/integration_runtime.py new file mode 100644 index 0000000000..a36dcc672c --- /dev/null +++ b/src/specify_cli/integration_runtime.py @@ -0,0 +1,90 @@ +"""Runtime helpers for integration commands.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from .integration_state import integration_setting, integration_settings + + +ParseOptions = Callable[[Any, str], dict[str, Any] | None] + + +def resolve_integration_options( + integration: Any, + state: dict[str, Any], + key: str, + raw_options: str | None, + *, + parse_options: ParseOptions, +) -> tuple[str | None, dict[str, Any] | None]: + """Resolve raw and parsed options for an integration operation.""" + if raw_options is not None: + return raw_options, parse_options(integration, raw_options) + + setting = integration_setting(state, key) + stored_raw = setting.get("raw_options") + if not isinstance(stored_raw, str): + stored_raw = None + + stored_parsed = setting.get("parsed_options") + if isinstance(stored_parsed, dict): + return stored_raw, stored_parsed or None + + if stored_raw: + return stored_raw, parse_options(integration, stored_raw) + + return None, None + + +def with_integration_setting( + state: dict[str, Any], + key: str, + integration: Any, + *, + script_type: str | None = None, + raw_options: str | None = None, + parsed_options: dict[str, Any] | None = None, +) -> dict[str, dict[str, Any]]: + """Return integration settings with *key* updated.""" + settings = integration_settings(state) + current = dict(settings.get(key, {})) + + if script_type: + current["script"] = script_type + if raw_options is not None: + current["raw_options"] = raw_options + elif "raw_options" in current and not current.get("raw_options"): + current.pop("raw_options", None) + + if parsed_options is not None: + current["parsed_options"] = parsed_options + elif raw_options is not None: + current.pop("parsed_options", None) + + current["invoke_separator"] = integration.effective_invoke_separator(parsed_options) + settings[key] = current + return settings + + +def invoke_separator_for_integration( + integration: Any, + state: dict[str, Any], + key: str, + parsed_options: dict[str, Any] | None = None, +) -> str: + """Resolve the invocation separator for stored/default integration state.""" + if parsed_options is not None: + return integration.effective_invoke_separator(parsed_options) + + setting = integration_setting(state, key) + stored_separator = setting.get("invoke_separator") + if isinstance(stored_separator, str) and stored_separator: + return stored_separator + + stored_parsed = setting.get("parsed_options") + if isinstance(stored_parsed, dict): + return integration.effective_invoke_separator(stored_parsed) + + return integration.effective_invoke_separator(None) diff --git a/src/specify_cli/integration_state.py b/src/specify_cli/integration_state.py new file mode 100644 index 0000000000..ac892dfbf6 --- /dev/null +++ b/src/specify_cli/integration_state.py @@ -0,0 +1,161 @@ +"""State helpers for installed AI agent integrations.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +INTEGRATION_JSON = ".specify/integration.json" +INTEGRATION_STATE_SCHEMA = 1 + + +def clean_integration_key(key: Any) -> str | None: + """Return a stripped integration key, or None for empty/non-string values.""" + if not isinstance(key, str) or not key.strip(): + return None + return key.strip() + + +def dedupe_integration_keys(keys: list[Any]) -> list[str]: + """Return a de-duplicated list of non-empty integration keys.""" + seen: set[str] = set() + deduped: list[str] = [] + for key in keys: + clean = clean_integration_key(key) + if clean is None: + continue + if clean in seen: + continue + seen.add(clean) + deduped.append(clean) + return deduped + + +def normalize_integration_settings(settings: Any) -> dict[str, dict[str, Any]]: + """Return JSON-safe per-integration runtime settings.""" + if not isinstance(settings, dict): + return {} + + normalized: dict[str, dict[str, Any]] = {} + for key, value in settings.items(): + if not isinstance(key, str) or not key.strip() or not isinstance(value, dict): + continue + + clean: dict[str, Any] = {} + script = value.get("script") + if isinstance(script, str) and script.strip(): + clean["script"] = script.strip() + + raw_options = value.get("raw_options") + if isinstance(raw_options, str): + clean["raw_options"] = raw_options + + parsed_options = value.get("parsed_options") + if isinstance(parsed_options, dict): + clean["parsed_options"] = parsed_options + + invoke_separator = value.get("invoke_separator") + if isinstance(invoke_separator, str) and invoke_separator.strip(): + clean["invoke_separator"] = invoke_separator.strip() + + if clean: + normalized[key.strip()] = clean + + return normalized + + +def _normalized_integration_state_schema(value: Any) -> int: + if isinstance(value, int) and not isinstance(value, bool) and value > INTEGRATION_STATE_SCHEMA: + return value + return INTEGRATION_STATE_SCHEMA + + +def normalize_integration_state(data: dict[str, Any]) -> dict[str, Any]: + """Normalize legacy and multi-install integration metadata.""" + legacy_key = clean_integration_key(data.get("integration")) + default_key = clean_integration_key(data.get("default_integration")) or legacy_key + + installed = data.get("installed_integrations") + installed_keys = dedupe_integration_keys(installed if isinstance(installed, list) else []) + if not default_key and installed_keys: + default_key = installed_keys[0] + if default_key and default_key not in installed_keys: + installed_keys.insert(0, default_key) + + settings = normalize_integration_settings(data.get("integration_settings")) + + normalized = dict(data) + normalized["integration_state_schema"] = _normalized_integration_state_schema( + data.get("integration_state_schema") + ) + if default_key: + normalized["integration"] = default_key + normalized["default_integration"] = default_key + else: + normalized.pop("integration", None) + normalized.pop("default_integration", None) + normalized["installed_integrations"] = installed_keys + normalized["integration_settings"] = { + key: settings[key] for key in installed_keys if key in settings + } + return normalized + + +def default_integration_key(state: dict[str, Any]) -> str | None: + """Return the default integration key from normalized state.""" + key = state.get("default_integration") or state.get("integration") + return clean_integration_key(key) + + +def installed_integration_keys(state: dict[str, Any]) -> list[str]: + """Return installed integration keys from normalized state.""" + return dedupe_integration_keys(state.get("installed_integrations", [])) + + +def integration_settings(state: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Return normalized per-integration settings from state.""" + return normalize_integration_settings(state.get("integration_settings")) + + +def integration_setting(state: dict[str, Any], key: str) -> dict[str, Any]: + """Return stored runtime settings for *key*.""" + return dict(integration_settings(state).get(key, {})) + + +def write_integration_json( + project_root: Path, + *, + version: str, + integration_key: str | None, + installed_integrations: list[str] | None = None, + settings: dict[str, dict[str, Any]] | None = None, +) -> None: + """Write ``.specify/integration.json`` with legacy-compatible state.""" + dest = project_root / INTEGRATION_JSON + dest.parent.mkdir(parents=True, exist_ok=True) + + integration_key = clean_integration_key(integration_key) + installed = dedupe_integration_keys(installed_integrations or []) + if integration_key and integration_key not in installed: + installed.insert(0, integration_key) + if not integration_key and installed: + integration_key = installed[0] + + normalized_settings = normalize_integration_settings(settings or {}) + normalized_settings = { + key: normalized_settings[key] for key in installed if key in normalized_settings + } + + data: dict[str, Any] = { + "version": version, + "integration_state_schema": INTEGRATION_STATE_SCHEMA, + "installed_integrations": installed, + "integration_settings": normalized_settings, + } + if integration_key: + data["integration"] = integration_key + data["default_integration"] = integration_key + + dest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index c65013869e..4a78e7d035 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -36,6 +36,7 @@ def get_integration(key: str) -> IntegrationBase | None: # -- Register built-in integrations -------------------------------------- + def _register_builtins() -> None: """Register all built-in integrations. @@ -51,18 +52,21 @@ def _register_builtins() -> None: from .auggie import AuggieIntegration from .bob import BobIntegration from .claude import ClaudeIntegration - from .codex import CodexIntegration from .codebuddy import CodebuddyIntegration + from .codex import CodexIntegration from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration + from .devin import DevinIntegration 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 .lingma import LingmaIntegration from .opencode import OpencodeIntegration from .pi import PiIntegration from .qodercli import QodercliIntegration @@ -80,18 +84,21 @@ def _register_builtins() -> None: _register(AuggieIntegration()) _register(BobIntegration()) _register(ClaudeIntegration()) - _register(CodexIntegration()) _register(CodebuddyIntegration()) + _register(CodexIntegration()) _register(CopilotIntegration()) _register(CursorAgentIntegration()) + _register(DevinIntegration()) _register(ForgeIntegration()) _register(GeminiIntegration()) _register(GenericIntegration()) + _register(GooseIntegration()) _register(IflowIntegration()) _register(JunieIntegration()) _register(KilocodeIntegration()) _register(KimiIntegration()) _register(KiroCliIntegration()) + _register(LingmaIntegration()) _register(OpencodeIntegration()) _register(PiIntegration()) _register(QodercliIntegration()) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 9cd522745e..d62bafad40 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -1,13 +1,18 @@ """Antigravity (agy) integration — skills-based agent. -Antigravity uses ``.agent/skills/speckit-/SKILL.md`` layout. -Explicit command support was deprecated in version 1.20.5; -``--skills`` defaults to ``True``. +Antigravity uses ``.agents/skills/speckit-/SKILL.md`` layout (enforced since v1.20.5). """ from __future__ import annotations -from ..base import IntegrationOption, SkillsIntegration +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from ..base import SkillsIntegration + +if TYPE_CHECKING: + from ..manifest import IntegrationManifest + class AgyIntegration(SkillsIntegration): @@ -16,26 +21,32 @@ class AgyIntegration(SkillsIntegration): key = "agy" config = { "name": "Antigravity", - "folder": ".agent/", + "folder": ".agents/", "commands_subdir": "skills", "install_url": None, "requires_cli": False, } registrar_config = { - "dir": ".agent/skills", + "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 Antigravity since v1.20.5)", - ), - ] + 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/agy/scripts/update-context.ps1 b/src/specify_cli/integrations/agy/scripts/update-context.ps1 deleted file mode 100644 index 9eeb461657..0000000000 --- a/src/specify_cli/integrations/agy/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Antigravity (agy) integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy diff --git a/src/specify_cli/integrations/agy/scripts/update-context.sh b/src/specify_cli/integrations/agy/scripts/update-context.sh deleted file mode 100755 index d7303f6197..0000000000 --- a/src/specify_cli/integrations/agy/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Antigravity (agy) integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -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" agy diff --git a/src/specify_cli/integrations/amp/scripts/update-context.ps1 b/src/specify_cli/integrations/amp/scripts/update-context.ps1 deleted file mode 100644 index c217b99f9a..0000000000 --- a/src/specify_cli/integrations/amp/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Amp integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType amp diff --git a/src/specify_cli/integrations/amp/scripts/update-context.sh b/src/specify_cli/integrations/amp/scripts/update-context.sh deleted file mode 100755 index 56cbf6e787..0000000000 --- a/src/specify_cli/integrations/amp/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Amp integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" amp diff --git a/src/specify_cli/integrations/auggie/__init__.py b/src/specify_cli/integrations/auggie/__init__.py index 9715e936ef..08e20fbc25 100644 --- a/src/specify_cli/integrations/auggie/__init__.py +++ b/src/specify_cli/integrations/auggie/__init__.py @@ -19,3 +19,4 @@ class AuggieIntegration(MarkdownIntegration): "extension": ".md", } context_file = ".augment/rules/specify-rules.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.ps1 b/src/specify_cli/integrations/auggie/scripts/update-context.ps1 deleted file mode 100644 index 49e7e6b5f3..0000000000 --- a/src/specify_cli/integrations/auggie/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Auggie CLI integration: create/update .augment/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType auggie diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.sh b/src/specify_cli/integrations/auggie/scripts/update-context.sh deleted file mode 100755 index 4cf80bba2b..0000000000 --- a/src/specify_cli/integrations/auggie/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Auggie CLI integration: create/update .augment/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" auggie diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 1b09347dcd..7ce107caec 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -20,6 +20,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +import yaml + if TYPE_CHECKING: from .manifest import IntegrationManifest @@ -28,6 +30,7 @@ # IntegrationOption # --------------------------------------------------------------------------- + @dataclass(frozen=True) class IntegrationOption: """Declares an option that an integration accepts via ``--integration-options``. @@ -51,6 +54,7 @@ class IntegrationOption: # IntegrationBase — abstract base class # --------------------------------------------------------------------------- + class IntegrationBase(ABC): """Abstract base class every integration must implement. @@ -82,6 +86,22 @@ class IntegrationBase(ABC): 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``).""" + + multi_install_safe: bool = False + """Whether this integration is declared safe to install alongside others. + + Safe integrations must use a static, unique agent root, command directory, + and context file. Registry tests enforce those invariants for every + integration that sets this flag. + """ + + # -- Markers for managed context section ------------------------------ + + CONTEXT_MARKER_START = "" + CONTEXT_MARKER_END = "" + # -- Public API ------------------------------------------------------- @classmethod @@ -89,6 +109,136 @@ 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: @@ -261,23 +411,257 @@ def install_scripts( 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. Extract ``agent_scripts.`` and replace ``{AGENT_SCRIPT}`` - 4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter - 5. Replace ``{ARGS}`` with *arg_placeholder* - 6. Replace ``__AGENT__`` with *agent_name* + 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 = "" @@ -302,25 +686,7 @@ def process_template( if script_command: content = content.replace("{SCRIPT}", script_command) - # 3. Extract agent_script command - agent_script_command = "" - in_agent_scripts = False - for line in content.splitlines(): - if line.strip() == "agent_scripts:": - in_agent_scripts = True - continue - if in_agent_scripts and line and not line[0].isspace(): - in_agent_scripts = False - if in_agent_scripts: - m = script_pattern.match(line) - if m: - agent_script_command = m.group(1).strip() - break - - if agent_script_command: - content = content.replace("{AGENT_SCRIPT}", agent_script_command) - - # 4. Strip scripts: and agent_scripts: sections from frontmatter + # 3. Strip scripts: section from frontmatter lines = content.splitlines(keepends=True) output_lines: list[str] = [] in_frontmatter = False @@ -338,28 +704,36 @@ def process_template( output_lines.append(line) continue if in_frontmatter: - if stripped in ("scripts:", "agent_scripts:"): + if stripped == "scripts:": skip_section = True continue if skip_section: if line[0:1].isspace(): - continue # skip indented content under scripts/agent_scripts + continue # skip indented content under scripts skip_section = False output_lines.append(line) content = "".join(output_lines) - # 5. Replace {ARGS} + # 4. Replace {ARGS} and $ARGUMENTS content = content.replace("{ARGS}", arg_placeholder) + content = content.replace("$ARGUMENTS", arg_placeholder) - # 6. Replace __AGENT__ + # 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( @@ -405,6 +779,9 @@ def setup( 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( @@ -418,9 +795,11 @@ def teardown( 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 ------------------------------- @@ -433,9 +812,7 @@ def install( **opts: Any, ) -> list[Path]: """High-level install — calls ``setup()`` and returns created files.""" - return self.setup( - project_root, manifest, parsed_options=parsed_options, **opts - ) + return self.setup(project_root, manifest, parsed_options=parsed_options, **opts) def uninstall( self, @@ -452,6 +829,7 @@ def uninstall( # MarkdownIntegration — covers ~20 standard agents # --------------------------------------------------------------------------- + class MarkdownIntegration(IntegrationBase): """Concrete base for integrations that use standard Markdown commands. @@ -459,10 +837,26 @@ class MarkdownIntegration(IntegrationBase): (and optionally ``context_file``). Everything else is inherited. ``setup()`` processes command templates (replacing ``{SCRIPT}``, - ``{ARGS}``, ``__AGENT__``, rewriting paths) and installs - integration-specific scripts (``update-context.sh`` / ``.ps1``). + ``{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, @@ -492,19 +886,28 @@ def setup( 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" + 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) + 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) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created @@ -512,6 +915,7 @@ def setup( # TomlIntegration — TOML-format agents (Gemini, Tabnine) # --------------------------------------------------------------------------- + class TomlIntegration(IntegrationBase): """Concrete base for integrations that use TOML command format. @@ -524,6 +928,22 @@ class TomlIntegration(IntegrationBase): 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" @@ -536,7 +956,6 @@ def _extract_description(content: str) -> str: and ``>``) keep their YAML semantics instead of being treated as raw text. """ - import yaml frontmatter_text, _ = TomlIntegration._split_frontmatter(content) if not frontmatter_text: @@ -603,13 +1022,17 @@ def _render_toml_string(value: str) -> str: if "'''" not in value and not value.endswith("'"): return "'''\n" + value + "'''" - return '"' + ( - value.replace("\\", "\\\\") - .replace('"', '\\"') - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - ) + '"' + return ( + '"' + + ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + '"' + ) @staticmethod def _render_toml(description: str, body: str) -> str: @@ -628,7 +1051,9 @@ def _render_toml(description: str, body: str) -> str: toml_lines: list[str] = [] if description: - toml_lines.append(f"description = {TomlIntegration._render_toml_string(description)}") + toml_lines.append( + f"description = {TomlIntegration._render_toml_string(description)}" + ) toml_lines.append("") body = body.rstrip("\n") @@ -665,13 +1090,20 @@ def setup( 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}}" + 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) + 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) @@ -680,7 +1112,215 @@ def setup( ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # 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.""" + + 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() + + + @classmethod + def _build_yaml_header(cls, title: str, description: str) -> dict[str, Any]: + """Build the base YAML header.""" + header = { + "version": "1.0.0", + "title": title, + "description": description, + "author": {"contact": "spec-kit"}, + "parameters": [ + { + "key": "args", + "input_type": "string", + "requirement": "optional", + "default": "", + "description": "User input passed to the command.", + } + ], + "extensions": [{"type": "builtin", "name": "developer"}], + "activities": ["Spec-Driven Development"], + } + return header + + @classmethod + def _render_yaml(cls, 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. + """ + header = cls._build_yaml_header(title, description) + + header_yaml = yaml.safe_dump( + header, + sort_keys=False, + allow_unicode=True, + default_flow_style=False, + ).strip() + + # Indent the body 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 @@ -704,6 +1344,24 @@ class SkillsIntegration(IntegrationBase): ``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. @@ -713,9 +1371,7 @@ def skills_dest(self, project_root: Path) -> Path: Raises ``ValueError`` when ``config`` or ``folder`` is missing. """ if not self.config: - raise ValueError( - f"{type(self).__name__}.config is not set." - ) + raise ValueError(f"{type(self).__name__}.config is not set.") folder = self.config.get("folder") if not folder: raise ValueError( @@ -724,6 +1380,27 @@ def skills_dest(self, project_root: Path) -> Path: 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, @@ -737,7 +1414,6 @@ def setup( template. Each SKILL.md has normalised frontmatter containing ``name``, ``description``, ``compatibility``, and ``metadata``. """ - import yaml templates = self.list_command_templates() if not templates: @@ -788,7 +1464,9 @@ def setup( # Process body through the standard template pipeline processed_body = self.process_template( - raw, self.key, script_type, arg_placeholder + 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 @@ -832,5 +1510,7 @@ def _quote(v: str) -> str: ) created.append(dst) - created.extend(self.install_scripts(project_root, manifest)) + # 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/scripts/update-context.ps1 b/src/specify_cli/integrations/bob/scripts/update-context.ps1 deleted file mode 100644 index 188860899f..0000000000 --- a/src/specify_cli/integrations/bob/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — IBM Bob integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType bob diff --git a/src/specify_cli/integrations/bob/scripts/update-context.sh b/src/specify_cli/integrations/bob/scripts/update-context.sh deleted file mode 100755 index 0228603fea..0000000000 --- a/src/specify_cli/integrations/bob/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — IBM Bob integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" bob diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py new file mode 100644 index 0000000000..8ea1f4722c --- /dev/null +++ b/src/specify_cli/integrations/catalog.py @@ -0,0 +1,942 @@ +"""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, Tuple + +import yaml +from packaging import version as pkg_version + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +class IntegrationCatalogError(Exception): + """Raised when a catalog operation fails.""" + + +class IntegrationValidationError(IntegrationCatalogError): + """Validation error for catalog config or catalog management operations.""" + + +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: + IntegrationValidationError: on any local-config / YAML problem + (parse failures, wrong shape, missing/invalid fields, + invalid catalog URLs, etc.). This is a subclass of + :class:`IntegrationCatalogError`, so any caller that already + catches ``IntegrationCatalogError`` keeps working — but + callers that want to distinguish *local config* problems + from *remote/network* problems can match the subclass. + """ + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise IntegrationValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) from exc + if data is None: + data = {} + if not isinstance(data, dict): + raise IntegrationValidationError( + 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 IntegrationValidationError( + f"Invalid catalog config {config_path}: 'catalogs' must be a list, " + f"got {type(catalogs_data).__name__}" + ) + if not catalogs_data: + raise IntegrationValidationError( + 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 IntegrationValidationError( + f"Invalid catalog config {config_path}: 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 + try: + self._validate_catalog_url(url) + except IntegrationCatalogError as exc: + # ``_validate_catalog_url`` raises the base class for direct + # callers (e.g. ``add_catalog`` validating user input); when + # the bad URL came from a local config file, surface it as a + # validation error so CLI handlers can route it accordingly. + raise IntegrationValidationError( + f"Invalid catalog URL in {config_path} at index {idx}: {exc}" + ) from exc + raw_priority = item.get("priority", idx + 1) + if isinstance(raw_priority, bool): + raise IntegrationValidationError( + f"Invalid catalog config {config_path}: " + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {raw_priority!r}" + ) + try: + priority = int(raw_priority) + except (TypeError, ValueError): + raise IntegrationValidationError( + f"Invalid catalog config {config_path}: " + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {raw_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) + raw_name = item.get("name") + name = str(raw_name).strip() if raw_name is not None else "" + if not name: + name = f"catalog-{len(entries) + 1}" + entries.append( + IntegrationCatalogEntry( + url=url, + name=name, + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + ) + ) + entries.sort(key=lambda e: e.priority) + if not entries: + raise IntegrationValidationError( + 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" / self.CONFIG_FILENAME + catalogs = self._load_catalog_config(project_cfg) + if catalogs is not None: + return catalogs + + user_cfg = Path.home() / ".specify" / self.CONFIG_FILENAME + 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 + + 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: + from specify_cli.authentication.http import open_url + + with open_url(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) + + # -- Catalog-source management ---------------------------------------- + + CONFIG_FILENAME = "integration-catalogs.yml" + + def get_catalog_configs(self) -> List[Dict[str, Any]]: + """Return the active catalog stack as a list of dicts. + + Thin adapter over :meth:`get_active_catalogs` that yields plain dicts + suitable for CLI rendering and JSON-like consumers. + """ + return [ + { + "name": e.name, + "url": e.url, + "priority": e.priority, + "install_allowed": e.install_allowed, + "description": e.description, + } + for e in self.get_active_catalogs() + ] + + def get_project_catalog_configs(self) -> Optional[List[Dict[str, Any]]]: + """Return removable project-level catalog config entries, if configured.""" + config_path = self.project_root / ".specify" / self.CONFIG_FILENAME + entries = self._load_catalog_config(config_path) + if entries is None: + return None + 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: Optional[str] = None) -> None: + """Add a catalog source to the project-level config file. + + The URL is normalized (whitespace stripped) and validated before being + written. Duplicate URLs are rejected, including near-duplicates that + differ only by surrounding whitespace. Priority is derived as + ``max(existing) + 1`` so the new entry sorts last in the resolution + order unless the user edits the file manually. + """ + url = url.strip() + if not url: + raise IntegrationValidationError("Catalog URL must be non-empty.") + self._validate_catalog_url(url) + config_path = self.project_root / ".specify" / self.CONFIG_FILENAME + + data: Dict[str, Any] = {"catalogs": []} + if config_path.exists(): + try: + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise IntegrationValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) from exc + if raw is None: + raw = {} + if not isinstance(raw, dict): + raise IntegrationValidationError( + f"Catalog config file {config_path} is corrupted " + "(expected a mapping)." + ) + data = raw + + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise IntegrationValidationError( + f"Catalog config {config_path} has invalid 'catalogs' value: " + "must be a list." + ) + + # Validate each existing entry before mutating anything. Fail fast so + # we don't silently preserve a corrupt sibling entry or derive a new + # priority from a bogus value. + existing_priorities: List[int] = [] + valid_catalog_count = 0 + for idx, cat in enumerate(catalogs): + if not isinstance(cat, dict): + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: " + f"expected a mapping, got {type(cat).__name__}." + ) + existing_url = str(cat.get("url", "")).strip() + if not existing_url: + continue + # Re-run the same URL validation used when loading, so a corrupt + # entry surfaces here instead of at the next `integration` call. + try: + self._validate_catalog_url(existing_url) + except IntegrationCatalogError as exc: + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: {exc}" + ) from exc + if existing_url == url: + raise IntegrationValidationError( + f"Catalog URL already configured: {url}" + ) + valid_catalog_count += 1 + if "priority" in cat: + raw_priority = cat.get("priority") + if isinstance(raw_priority, bool): + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: " + f"'priority' must be an integer, got " + f"{type(raw_priority).__name__}." + ) + try: + normalized_priority = int(raw_priority) + except (TypeError, ValueError): + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: " + f"'priority' must be an integer, got " + f"{raw_priority!r}." + ) from None + existing_priorities.append(normalized_priority) + else: + # Match `_load_catalog_config()`'s defaulting rule so the new + # entry still sorts after implicit-priority siblings. + existing_priorities.append(idx + 1) + + max_priority = max(existing_priorities, default=0) + normalized_name = str(name).strip() if name is not None else "" + generated_name = f"catalog-{valid_catalog_count + 1}" + catalogs.append( + { + "name": normalized_name or generated_name, + "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 0-based index. + + ``index`` is interpreted in the same display order shown by + ``integration catalog list`` (i.e. sorted ascending by priority, + with missing priority defaulting to ``yaml_index + 1``, matching + ``_load_catalog_config()``). This way, the index a user sees in + ``catalog list`` is the index they pass to ``catalog remove``, + even if the underlying YAML lists entries in a different order + from how they sort by priority. + + Returns the removed catalog's name. + """ + config_path = self.project_root / ".specify" / self.CONFIG_FILENAME + if not config_path.exists(): + raise IntegrationValidationError("No catalog config file found.") + + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise IntegrationValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) from exc + if data is None: + data = {} + if not isinstance(data, dict): + raise IntegrationValidationError( + f"Catalog config file {config_path} is corrupted " + "(expected a mapping)." + ) + + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise IntegrationValidationError( + f"Catalog config {config_path} has invalid 'catalogs' value: " + "must be a list." + ) + + if not catalogs: + # An empty list is the kind of state that only happens if the + # user hand-edited the file; our own `remove_catalog` deletes + # the file when the last entry is popped. Surface a clear + # message instead of `out of range (0--1)`. + raise IntegrationValidationError( + "Catalog config contains no catalog entries." + ) + + # Map displayed index -> raw YAML index using the same priority + # defaulting as ``_load_catalog_config``. We deliberately stay + # tolerant here (no new validation errors) because the goal is + # only to mirror the order shown by ``catalog list``; entries + # that ``_load_catalog_config`` would have rejected outright + # would have failed ``catalog list`` already. + def _is_removable_catalog_entry(item: Any) -> bool: + if not isinstance(item, dict): + return False + raw_url = item.get("url") + if raw_url is None: + return False + return bool(str(raw_url).strip()) + + priority_pairs: List[Tuple[int, int]] = [] + for yaml_idx, item in enumerate(catalogs): + if not _is_removable_catalog_entry(item): + continue + + raw_priority = item.get("priority", yaml_idx + 1) + if isinstance(raw_priority, bool): + priority = yaml_idx + 1 + else: + try: + priority = int(raw_priority) + except (TypeError, ValueError): + priority = yaml_idx + 1 + priority_pairs.append((priority, yaml_idx)) + if not priority_pairs: + raise IntegrationValidationError( + "Catalog config contains no removable catalog entries." + ) + # Stable sort: ties keep their YAML order, matching list-view ordering. + priority_pairs.sort(key=lambda p: p[0]) + display_order: List[int] = [yaml_idx for _, yaml_idx in priority_pairs] + + if index < 0 or index >= len(display_order): + raise IntegrationValidationError( + f"Catalog index {index} out of range (0-{len(display_order) - 1})." + ) + + target_yaml_idx = display_order[index] + removed = catalogs.pop(target_yaml_idx) + + if any(_is_removable_catalog_entry(item) for item in catalogs): + 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, + ) + else: + # Removing the final entry: delete the config file rather than + # leaving behind an empty `catalogs:` list. `_load_catalog_config` + # treats an empty list as an error, so leaving the file would + # break every subsequent `integration` command until the user + # manually deletes `.specify/integration-catalogs.yml`. + # Deleting the file lets the project fall back to built-in + # defaults, which matches the behavior before any + # `catalog add` was ever run. + try: + config_path.unlink(missing_ok=True) + except OSError as exc: + raise IntegrationValidationError( + f"Failed to delete catalog config {config_path}: {exc}" + ) from exc + + fallback_name = f"catalog-{index + 1}" + if isinstance(removed, dict): + removed_name = removed.get("name") + if removed_name is not None: + normalized_name = str(removed_name).strip() + if normalized_name: + return normalized_name + + removed_url = removed.get("url") + if removed_url is not None: + normalized_url = str(removed_url).strip() + if normalized_url: + return normalized_url + return fallback_name + + +# --------------------------------------------------------------------------- +# 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 index 31972c4b0e..88aef85285 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -5,11 +5,21 @@ 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] = { @@ -43,6 +53,7 @@ class ClaudeIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = "CLAUDE.md" + multi_install_safe = True @staticmethod def inject_argument_hint(content: str, hint: str) -> str: @@ -148,6 +159,43 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str 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, @@ -155,7 +203,7 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint.""" + """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 @@ -173,11 +221,7 @@ def setup( content_bytes = path.read_bytes() content = content_bytes.decode("utf-8") - # Inject user-invocable: true (Claude skills are accessible via /command) - updated = self._inject_frontmatter_flag(content, "user-invocable") - - # Inject disable-model-invocation: true (Claude skills run only when invoked) - updated = self._inject_frontmatter_flag(updated, "disable-model-invocation") + 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" diff --git a/src/specify_cli/integrations/claude/scripts/update-context.ps1 b/src/specify_cli/integrations/claude/scripts/update-context.ps1 deleted file mode 100644 index 837974d47a..0000000000 --- a/src/specify_cli/integrations/claude/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Claude Code integration: create/update CLAUDE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType claude diff --git a/src/specify_cli/integrations/claude/scripts/update-context.sh b/src/specify_cli/integrations/claude/scripts/update-context.sh deleted file mode 100755 index 4b83855a27..0000000000 --- a/src/specify_cli/integrations/claude/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Claude Code integration: create/update CLAUDE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" claude diff --git a/src/specify_cli/integrations/codebuddy/__init__.py b/src/specify_cli/integrations/codebuddy/__init__.py index 061ac7641f..980ac7fed7 100644 --- a/src/specify_cli/integrations/codebuddy/__init__.py +++ b/src/specify_cli/integrations/codebuddy/__init__.py @@ -19,3 +19,4 @@ class CodebuddyIntegration(MarkdownIntegration): "extension": ".md", } context_file = "CODEBUDDY.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 b/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 deleted file mode 100644 index 0269392c09..0000000000 --- a/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — CodeBuddy integration: create/update CODEBUDDY.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codebuddy diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.sh b/src/specify_cli/integrations/codebuddy/scripts/update-context.sh deleted file mode 100755 index d57ddc3560..0000000000 --- a/src/specify_cli/integrations/codebuddy/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — CodeBuddy integration: create/update CODEBUDDY.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" codebuddy diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index f6415f9bb2..1c24a84bd2 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -27,6 +27,22 @@ class CodexIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = "AGENTS.md" + multi_install_safe = True + + 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]: diff --git a/src/specify_cli/integrations/codex/scripts/update-context.ps1 b/src/specify_cli/integrations/codex/scripts/update-context.ps1 deleted file mode 100644 index d73a5a4d34..0000000000 --- a/src/specify_cli/integrations/codex/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Codex CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codex diff --git a/src/specify_cli/integrations/codex/scripts/update-context.sh b/src/specify_cli/integrations/codex/scripts/update-context.sh deleted file mode 100755 index 512d6e91d3..0000000000 --- a/src/specify_cli/integrations/codex/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Codex CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -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" codex diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 036f2e1db7..c7456ce7f0 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -5,28 +5,91 @@ - 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 +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 in VS Code.""" + """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": None, + "install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli", "requires_cli": False, } registrar_config = { @@ -37,10 +100,213 @@ class CopilotIntegration(IntegrationBase): } 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, @@ -50,10 +316,24 @@ def setup( ) -> list[Path]: """Install copilot commands, companion prompts, and VS Code settings. - Uses base class primitives to: read templates, process them - (replace placeholders, strip script blocks, rewrite paths), - write as ``.agent.md``, then add 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( @@ -83,7 +363,10 @@ def setup( # 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) + 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 @@ -117,8 +400,39 @@ def setup( self.record_file_in_manifest(dst_settings, project_root, manifest) created.append(dst_settings) - # 4. Install integration-specific update-context scripts - created.extend(self.install_scripts(project_root, manifest)) + # 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 diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 b/src/specify_cli/integrations/copilot/scripts/update-context.ps1 deleted file mode 100644 index 26e746a789..0000000000 --- a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 +++ /dev/null @@ -1,32 +0,0 @@ -# update-context.ps1 — Copilot integration: create/update .github/copilot-instructions.md -# -# This is the copilot-specific implementation that produces the GitHub -# Copilot instructions file. The shared dispatcher reads -# .specify/integration.json and calls this script. -# -# NOTE: This script is not yet active. It will be activated in Stage 7 -# when the shared update-agent-context.ps1 replaces its switch statement -# with integration.json-based dispatch. The shared script must also be -# refactored to support SPECKIT_SOURCE_ONLY (guard the Main call) before -# dot-sourcing will work. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -# Invoke shared update-agent-context script as a separate process. -# Dot-sourcing is unsafe until that script guards its Main call. -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType copilot diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.sh b/src/specify_cli/integrations/copilot/scripts/update-context.sh deleted file mode 100644 index c7f3bc60b5..0000000000 --- a/src/specify_cli/integrations/copilot/scripts/update-context.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Copilot integration: create/update .github/copilot-instructions.md -# -# This is the copilot-specific implementation that produces the GitHub -# Copilot instructions file. The shared dispatcher reads -# .specify/integration.json and calls this script. -# -# NOTE: This script is not yet active. It will be activated in Stage 7 -# when the shared update-agent-context.sh replaces its case statement -# with integration.json-based dispatch. The shared script must also be -# refactored to support SPECKIT_SOURCE_ONLY (guard the main logic) -# before sourcing will work. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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 - -# Invoke shared update-agent-context script as a separate process. -# Sourcing is unsafe until that script guards its main logic. -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" copilot diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index c244a7c01a..70af454ce9 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -1,21 +1,40 @@ -"""Cursor IDE integration.""" +"""Cursor IDE integration. -from ..base import MarkdownIntegration +Cursor Agent uses the ``.cursor/skills/speckit-/SKILL.md`` layout. +Commands are deprecated; ``--skills`` defaults to ``True``. +""" +from __future__ import annotations -class CursorAgentIntegration(MarkdownIntegration): +from ..base import IntegrationOption, SkillsIntegration + + +class CursorAgentIntegration(SkillsIntegration): key = "cursor-agent" config = { "name": "Cursor", "folder": ".cursor/", - "commands_subdir": "commands", + "commands_subdir": "skills", "install_url": None, "requires_cli": False, } registrar_config = { - "dir": ".cursor/commands", + "dir": ".cursor/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } + context_file = ".cursor/rules/specify-rules.mdc" + multi_install_safe = True + + @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/cursor_agent/scripts/update-context.ps1 b/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 deleted file mode 100644 index 4ce50a4873..0000000000 --- a/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Cursor integration: create/update .cursor/rules/specify-rules.mdc -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType cursor-agent diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh b/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh deleted file mode 100755 index 597ca2289c..0000000000 --- a/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Cursor integration: create/update .cursor/rules/specify-rules.mdc -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" cursor-agent diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py new file mode 100644 index 0000000000..f5656e4aef --- /dev/null +++ b/src/specify_cli/integrations/devin/__init__.py @@ -0,0 +1,65 @@ +"""Devin for Terminal integration — skills-based agent. + +Devin uses the ``.devin/skills/speckit-/SKILL.md`` layout and +reads project context from ``AGENTS.md`` at the repo root. The CLI +binary is ``devin`` and skills are invoked via ``/`` inside an +interactive ``devin`` session. + +See: https://cli.devin.ai/docs/extensibility/skills/overview +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class DevinIntegration(SkillsIntegration): + """Integration for Cognition AI's Devin for Terminal.""" + + key = "devin" + config = { + "name": "Devin for Terminal", + "folder": ".devin/", + "commands_subdir": "skills", + "install_url": "https://cli.devin.ai/docs", + "requires_cli": True, + } + registrar_config = { + "dir": ".devin/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: + """Build non-interactive CLI args for Devin for Terminal. + + Devin supports ``devin -p `` for single-turn execution + and ``--model`` for model selection, but its CLI has no flag + for structured JSON output. When ``output_json`` is requested, + Devin is still dispatched normally and returns plain-text + stdout instead of structured JSON. ``requires_cli=True`` is + kept on the integration for tool detection. + """ + args = [self.key, "-p", prompt] + if model: + args.extend(["--model", model]) + return args + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Devin)", + ), + ] \ No newline at end of file diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index e3d5347270..47a90687dc 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -4,6 +4,7 @@ - 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 @@ -15,6 +16,52 @@ 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). @@ -39,8 +86,11 @@ class ForgeIntegration(MarkdownIntegration): "extension": ".md", "strip_frontmatter_keys": ["handoffs"], "inject_name": True, + "format_name": format_forge_command_name, # Custom name formatter + "invoke_separator": "-", } context_file = "AGENTS.md" + invoke_separator = "-" def setup( self, @@ -82,7 +132,11 @@ def setup( 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) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + invoke_separator=self.invoke_separator, + ) # FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are # converted to {{parameters}} @@ -97,8 +151,8 @@ def setup( ) created.append(dst_file) - # Install integration-specific update-context scripts - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) return created @@ -106,7 +160,7 @@ 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 + 2. Inject 'name' field if missing (using hyphenated format) """ # Parse frontmatter lines = content.split('\n') @@ -143,11 +197,11 @@ def _apply_forge_transformations(self, content: str, template_name: str) -> str: filtered_frontmatter.append(line) - # 2. Inject 'name' field if missing + # 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 the template name as the command name (e.g., "plan" -> "speckit.plan") - cmd_name = f"speckit.{template_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 diff --git a/src/specify_cli/integrations/forge/scripts/update-context.ps1 b/src/specify_cli/integrations/forge/scripts/update-context.ps1 deleted file mode 100644 index 474a9c6d0b..0000000000 --- a/src/specify_cli/integrations/forge/scripts/update-context.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -# update-context.ps1 — Forge integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if (-not (Test-Path $sharedScript)) { - Write-Error "Error: shared agent context updater not found: $sharedScript" - Write-Error "Forge integration requires support in scripts/powershell/update-agent-context.ps1." - exit 1 -} - -& $sharedScript -AgentType forge -exit $LASTEXITCODE diff --git a/src/specify_cli/integrations/forge/scripts/update-context.sh b/src/specify_cli/integrations/forge/scripts/update-context.sh deleted file mode 100755 index 2a5c46e1d1..0000000000 --- a/src/specify_cli/integrations/forge/scripts/update-context.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Forge integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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 - -shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if [ ! -x "$shared_script" ]; then - echo "Error: shared agent context updater not found or not executable:" >&2 - echo " $shared_script" >&2 - echo "Forge integration requires support in scripts/bash/update-agent-context.sh." >&2 - exit 1 -fi - -exec "$shared_script" forge diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py index d66f0b80bc..7c6fe159c7 100644 --- a/src/specify_cli/integrations/gemini/__init__.py +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -19,3 +19,4 @@ class GeminiIntegration(TomlIntegration): "extension": ".toml", } context_file = "GEMINI.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 b/src/specify_cli/integrations/gemini/scripts/update-context.ps1 deleted file mode 100644 index 51c9e0bc83..0000000000 --- a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Gemini CLI integration: create/update GEMINI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType gemini diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.sh b/src/specify_cli/integrations/gemini/scripts/update-context.sh deleted file mode 100644 index c4e5003a55..0000000000 --- a/src/specify_cli/integrations/gemini/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Gemini CLI integration: create/update GEMINI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" gemini diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index 4107c48690..fdaee4ed04 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -31,7 +31,7 @@ class GenericIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = None + context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -122,12 +122,17 @@ def setup( for src_file in templates: raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + 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) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created diff --git a/src/specify_cli/integrations/generic/scripts/update-context.ps1 b/src/specify_cli/integrations/generic/scripts/update-context.ps1 deleted file mode 100644 index 2e9467f801..0000000000 --- a/src/specify_cli/integrations/generic/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Generic integration: create/update context file -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType generic diff --git a/src/specify_cli/integrations/generic/scripts/update-context.sh b/src/specify_cli/integrations/generic/scripts/update-context.sh deleted file mode 100755 index d8ad30a7b8..0000000000 --- a/src/specify_cli/integrations/generic/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Generic integration: create/update context file -# -# Thin wrapper that delegates to the shared update-agent-context script. - -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" generic 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 index 4acc2cf372..65d4d21c63 100644 --- a/src/specify_cli/integrations/iflow/__init__.py +++ b/src/specify_cli/integrations/iflow/__init__.py @@ -19,3 +19,4 @@ class IflowIntegration(MarkdownIntegration): "extension": ".md", } context_file = "IFLOW.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.ps1 b/src/specify_cli/integrations/iflow/scripts/update-context.ps1 deleted file mode 100644 index b502d4182a..0000000000 --- a/src/specify_cli/integrations/iflow/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — iFlow CLI integration: create/update IFLOW.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType iflow diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.sh b/src/specify_cli/integrations/iflow/scripts/update-context.sh deleted file mode 100755 index 5080402071..0000000000 --- a/src/specify_cli/integrations/iflow/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — iFlow CLI integration: create/update IFLOW.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" iflow diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 0cc3b3f0ff..98d0494a8a 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -19,3 +19,4 @@ class JunieIntegration(MarkdownIntegration): "extension": ".md", } context_file = ".junie/AGENTS.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/junie/scripts/update-context.ps1 b/src/specify_cli/integrations/junie/scripts/update-context.ps1 deleted file mode 100644 index 5a32432132..0000000000 --- a/src/specify_cli/integrations/junie/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Junie integration: create/update .junie/AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType junie diff --git a/src/specify_cli/integrations/junie/scripts/update-context.sh b/src/specify_cli/integrations/junie/scripts/update-context.sh deleted file mode 100755 index f4c8ba6c0e..0000000000 --- a/src/specify_cli/integrations/junie/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Junie integration: create/update .junie/AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" junie diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py index ffd38f741a..11674dd9f1 100644 --- a/src/specify_cli/integrations/kilocode/__init__.py +++ b/src/specify_cli/integrations/kilocode/__init__.py @@ -19,3 +19,4 @@ class KilocodeIntegration(MarkdownIntegration): "extension": ".md", } context_file = ".kilocode/rules/specify-rules.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 b/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 deleted file mode 100644 index d87e7ef59f..0000000000 --- a/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Kilo Code integration: create/update .kilocode/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kilocode diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.sh b/src/specify_cli/integrations/kilocode/scripts/update-context.sh deleted file mode 100755 index 132c0403f3..0000000000 --- a/src/specify_cli/integrations/kilocode/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kilo Code integration: create/update .kilocode/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" kilocode diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 5421d48012..3b257768e2 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -36,6 +36,7 @@ class KimiIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = "KIMI.md" + multi_install_safe = True @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 b/src/specify_cli/integrations/kimi/scripts/update-context.ps1 deleted file mode 100644 index aa6678d052..0000000000 --- a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Kimi Code integration: create/update KIMI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kimi diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.sh b/src/specify_cli/integrations/kimi/scripts/update-context.sh deleted file mode 100755 index 2f81bc2a48..0000000000 --- a/src/specify_cli/integrations/kimi/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kimi Code integration: create/update KIMI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -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" kimi diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 b/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 deleted file mode 100644 index 7dd2b35fb7..0000000000 --- a/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Kiro CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kiro-cli diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh b/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh deleted file mode 100755 index fa258edc75..0000000000 --- a/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kiro CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" kiro-cli diff --git a/src/specify_cli/integrations/lingma/__init__.py b/src/specify_cli/integrations/lingma/__init__.py new file mode 100644 index 0000000000..b5cd036033 --- /dev/null +++ b/src/specify_cli/integrations/lingma/__init__.py @@ -0,0 +1,41 @@ +"""Lingma IDE integration. — skills-based agent. + +Lingma IDE uses ``.lingma/skills/speckit-/SKILL.md`` layout. +In Specify CLI, the Lingma integration is skills-only, and ``--skills`` +defaults to ``True``. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class LingmaIntegration(SkillsIntegration): + """Integration for Lingma IDE.""" + + key = "lingma" + config = { + "name": "Lingma", + "folder": ".lingma/", + "commands_subdir": "skills", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".lingma/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = ".lingma/rules/specify-rules.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills", + ), + ] diff --git a/src/specify_cli/integrations/manifest.py b/src/specify_cli/integrations/manifest.py index 50ac08ea3d..258c536e5b 100644 --- a/src/specify_cli/integrations/manifest.py +++ b/src/specify_cli/integrations/manifest.py @@ -11,6 +11,7 @@ import hashlib import json import os +import tempfile from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -47,6 +48,59 @@ def _validate_rel_path(rel: Path, root: Path) -> Path: return resolved +def _manifest_path_label(root: Path, path: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() + + +def _ensure_safe_manifest_directory(root: Path, directory: Path) -> None: + """Create a manifest directory without following symlinked parents.""" + root_resolved = root.resolve() + try: + rel = directory.relative_to(root) + except ValueError: + label = _manifest_path_label(root, directory) + raise ValueError(f"Integration manifest directory escapes project root: {label}") from None + + current = root + for part in rel.parts: + current = current / part + label = _manifest_path_label(root, current) + if current.is_symlink(): + raise ValueError(f"Refusing to use symlinked integration manifest directory: {label}") + if current.exists(): + if not current.is_dir(): + raise ValueError(f"Integration manifest directory path is not a directory: {label}") + try: + current.resolve().relative_to(root_resolved) + except (OSError, ValueError): + raise ValueError(f"Integration manifest directory escapes project root: {label}") from None + continue + current.mkdir() + try: + current.resolve().relative_to(root_resolved) + except (OSError, ValueError): + raise ValueError(f"Integration manifest directory escapes project root: {label}") from None + + +def _ensure_safe_manifest_destination(root: Path, path: Path) -> None: + """Refuse manifest writes that would escape the project or follow symlinks.""" + root_resolved = root.resolve() + _ensure_safe_manifest_directory(root, path.parent) + label = _manifest_path_label(root, path) + if path.is_symlink(): + raise ValueError(f"Refusing to overwrite symlinked integration manifest path: {label}") + if path.exists(): + if not path.is_file(): + raise ValueError(f"Integration manifest path is not a file: {label}") + try: + path.resolve().relative_to(root_resolved) + except (OSError, ValueError): + raise ValueError(f"Integration manifest path escapes project root: {label}") from None + + class IntegrationManifest: """Tracks files installed by a single integration. @@ -217,8 +271,19 @@ def save(self) -> Path: "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") + content = json.dumps(data, indent=2) + "\n" + _ensure_safe_manifest_destination(self.project_root, path) + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temp_path = Path(temp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + temp_path.chmod(0o644) + _ensure_safe_manifest_destination(self.project_root, path) + os.replace(temp_path, path) + finally: + if temp_path.exists(): + temp_path.unlink() return path @classmethod diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index be4dcc3094..17db2bd11b 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -19,3 +19,27 @@ class OpencodeIntegration(MarkdownIntegration): "extension": ".md", } context_file = "AGENTS.md" + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + args = [self.key, "run"] + + message = prompt + if prompt.startswith("/"): + command, _, remainder = prompt[1:].partition(" ") + if command: + args.extend(["--command", command]) + message = remainder + + if model: + args.extend(["-m", model]) + if output_json: + args.extend(["--format", "json"]) + if message: + args.append(message) + return args diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.ps1 b/src/specify_cli/integrations/opencode/scripts/update-context.ps1 deleted file mode 100644 index 4bba02b455..0000000000 --- a/src/specify_cli/integrations/opencode/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — opencode integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType opencode diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.sh b/src/specify_cli/integrations/opencode/scripts/update-context.sh deleted file mode 100755 index 24c7e60251..0000000000 --- a/src/specify_cli/integrations/opencode/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — opencode integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" opencode diff --git a/src/specify_cli/integrations/pi/scripts/update-context.ps1 b/src/specify_cli/integrations/pi/scripts/update-context.ps1 deleted file mode 100644 index 6362118a5b..0000000000 --- a/src/specify_cli/integrations/pi/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Pi Coding Agent integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType pi diff --git a/src/specify_cli/integrations/pi/scripts/update-context.sh b/src/specify_cli/integrations/pi/scripts/update-context.sh deleted file mode 100755 index 1ad84c95a2..0000000000 --- a/src/specify_cli/integrations/pi/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Pi Coding Agent integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" pi diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py index 541001be17..ee2d4b6255 100644 --- a/src/specify_cli/integrations/qodercli/__init__.py +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -19,3 +19,4 @@ class QodercliIntegration(MarkdownIntegration): "extension": ".md", } context_file = "QODER.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 b/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 deleted file mode 100644 index 1fa007a168..0000000000 --- a/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Qoder CLI integration: create/update QODER.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qodercli diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.sh b/src/specify_cli/integrations/qodercli/scripts/update-context.sh deleted file mode 100755 index d371ad7952..0000000000 --- a/src/specify_cli/integrations/qodercli/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Qoder CLI integration: create/update QODER.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" qodercli diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py index d9d930152c..2506a57681 100644 --- a/src/specify_cli/integrations/qwen/__init__.py +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -19,3 +19,4 @@ class QwenIntegration(MarkdownIntegration): "extension": ".md", } context_file = "QWEN.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.ps1 b/src/specify_cli/integrations/qwen/scripts/update-context.ps1 deleted file mode 100644 index 24e4c90fab..0000000000 --- a/src/specify_cli/integrations/qwen/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Qwen Code integration: create/update QWEN.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qwen diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.sh b/src/specify_cli/integrations/qwen/scripts/update-context.sh deleted file mode 100755 index d1c62eb161..0000000000 --- a/src/specify_cli/integrations/qwen/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Qwen Code integration: create/update QWEN.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" qwen diff --git a/src/specify_cli/integrations/roo/__init__.py b/src/specify_cli/integrations/roo/__init__.py index 3c680e7e35..f610a3cc63 100644 --- a/src/specify_cli/integrations/roo/__init__.py +++ b/src/specify_cli/integrations/roo/__init__.py @@ -19,3 +19,4 @@ class RooIntegration(MarkdownIntegration): "extension": ".md", } context_file = ".roo/rules/specify-rules.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/roo/scripts/update-context.ps1 b/src/specify_cli/integrations/roo/scripts/update-context.ps1 deleted file mode 100644 index d1dec923ed..0000000000 --- a/src/specify_cli/integrations/roo/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Roo Code integration: create/update .roo/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType roo diff --git a/src/specify_cli/integrations/roo/scripts/update-context.sh b/src/specify_cli/integrations/roo/scripts/update-context.sh deleted file mode 100755 index 8fe255cb1b..0000000000 --- a/src/specify_cli/integrations/roo/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Roo Code integration: create/update .roo/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" roo diff --git a/src/specify_cli/integrations/shai/__init__.py b/src/specify_cli/integrations/shai/__init__.py index 7a9d1deb02..123953da72 100644 --- a/src/specify_cli/integrations/shai/__init__.py +++ b/src/specify_cli/integrations/shai/__init__.py @@ -19,3 +19,4 @@ class ShaiIntegration(MarkdownIntegration): "extension": ".md", } context_file = "SHAI.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/shai/scripts/update-context.ps1 b/src/specify_cli/integrations/shai/scripts/update-context.ps1 deleted file mode 100644 index 2c621c76ac..0000000000 --- a/src/specify_cli/integrations/shai/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — SHAI integration: create/update SHAI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType shai diff --git a/src/specify_cli/integrations/shai/scripts/update-context.sh b/src/specify_cli/integrations/shai/scripts/update-context.sh deleted file mode 100755 index 093b9d1f76..0000000000 --- a/src/specify_cli/integrations/shai/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — SHAI integration: create/update SHAI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" shai diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py index 2928a214a7..0d0076bc56 100644 --- a/src/specify_cli/integrations/tabnine/__init__.py +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -19,3 +19,4 @@ class TabnineIntegration(TomlIntegration): "extension": ".toml", } context_file = "TABNINE.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 b/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 deleted file mode 100644 index 0ffb3a1649..0000000000 --- a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Tabnine CLI integration: create/update TABNINE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType tabnine diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.sh b/src/specify_cli/integrations/tabnine/scripts/update-context.sh deleted file mode 100644 index fe5050b6e9..0000000000 --- a/src/specify_cli/integrations/tabnine/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Tabnine CLI integration: create/update TABNINE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" tabnine diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py index 343a7527f8..4556487d07 100644 --- a/src/specify_cli/integrations/trae/__init__.py +++ b/src/specify_cli/integrations/trae/__init__.py @@ -27,6 +27,7 @@ class TraeIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = ".trae/rules/project_rules.md" + multi_install_safe = True @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/trae/scripts/update-context.ps1 b/src/specify_cli/integrations/trae/scripts/update-context.ps1 deleted file mode 100644 index ae9a3d1cd0..0000000000 --- a/src/specify_cli/integrations/trae/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Trae integration: create/update .trae/rules/project_rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType trae diff --git a/src/specify_cli/integrations/trae/scripts/update-context.sh b/src/specify_cli/integrations/trae/scripts/update-context.sh deleted file mode 100755 index 32e5c16b29..0000000000 --- a/src/specify_cli/integrations/trae/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Trae integration: create/update .trae/rules/project_rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" trae diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index dcc4a60dda..f5ad63bdc2 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -1,21 +1,133 @@ -"""Mistral Vibe CLI integration.""" +""" +Mistral Vibe CLI integration — skills-based agent. -from ..base import MarkdownIntegration +Vibe uses ``.vibe/skills/speckit-/SKILL.md`` layout (enforced since v2.0.0). +""" +from __future__ import annotations -class VibeIntegration(MarkdownIntegration): +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": "prompts", + "commands_subdir": "skills", "install_url": "https://github.com/mistralai/mistral-vibe", "requires_cli": True, } registrar_config = { - "dir": ".vibe/prompts", + "dir": ".vibe/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } - context_file = ".vibe/agents/specify-agents.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/vibe/scripts/update-context.ps1 b/src/specify_cli/integrations/vibe/scripts/update-context.ps1 deleted file mode 100644 index d82ce3389c..0000000000 --- a/src/specify_cli/integrations/vibe/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Mistral Vibe integration: create/update .vibe/agents/specify-agents.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType vibe diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.sh b/src/specify_cli/integrations/vibe/scripts/update-context.sh deleted file mode 100755 index f924cdb896..0000000000 --- a/src/specify_cli/integrations/vibe/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Mistral Vibe integration: create/update .vibe/agents/specify-agents.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" vibe diff --git a/src/specify_cli/integrations/windsurf/__init__.py b/src/specify_cli/integrations/windsurf/__init__.py index f0f77d318e..ae5c3301f4 100644 --- a/src/specify_cli/integrations/windsurf/__init__.py +++ b/src/specify_cli/integrations/windsurf/__init__.py @@ -19,3 +19,4 @@ class WindsurfIntegration(MarkdownIntegration): "extension": ".md", } context_file = ".windsurf/rules/specify-rules.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 b/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 deleted file mode 100644 index b5fe1d0c0a..0000000000 --- a/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Windsurf integration: create/update .windsurf/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -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 - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType windsurf diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.sh b/src/specify_cli/integrations/windsurf/scripts/update-context.sh deleted file mode 100755 index b9a78d320e..0000000000 --- a/src/specify_cli/integrations/windsurf/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Windsurf integration: create/update .windsurf/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_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" windsurf diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 137d1d22a8..041c832e45 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -16,7 +16,10 @@ import shutil from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, List, Any +from typing import TYPE_CHECKING, Optional, Dict, List, Any + +if TYPE_CHECKING: + from .agents import CommandRegistrar from datetime import datetime, timezone import re @@ -24,7 +27,60 @@ from packaging import version as pkg_version from packaging.specifiers import SpecifierSet, InvalidSpecifier -from .extensions import ExtensionRegistry, normalize_priority +from .extensions import REINSTALL_COMMAND, 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 @@ -53,6 +109,9 @@ class PresetCompatibilityError(PresetError): 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: @@ -77,12 +136,25 @@ def __init__(self, manifest_path: Path): def _load_yaml(self, path: Path) -> dict: """Load YAML file safely.""" try: - with open(path, 'r') as f: - return yaml.safe_load(f) or {} + with open(path, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) except yaml.YAMLError as e: raise PresetValidationError(f"Invalid YAML in {path}: {e}") except FileNotFoundError: raise PresetValidationError(f"Manifest not found: {path}") + except UnicodeDecodeError as e: + raise PresetValidationError( + f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})" + ) + except OSError as e: + raise PresetValidationError(f"Could not read manifest {path}: {e}") + if data is None: + return {} + if not isinstance(data, dict): + raise PresetValidationError( + f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}" + ) + return data def _validate(self): """Validate manifest structure and required fields.""" @@ -151,6 +223,28 @@ def _validate(self): "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) @@ -482,7 +576,7 @@ def check_compatibility( 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" + f"Upgrade spec-kit with: {REINSTALL_COMMAND}" ) except InvalidSpecifier: raise PresetCompatibilityError( @@ -502,6 +596,10 @@ def _register_commands( 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 @@ -531,6 +629,50 @@ def _register_commands( 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: @@ -538,7 +680,7 @@ def _register_commands( registrar = CommandRegistrar() return registrar.register_commands_for_all_agents( - filtered, manifest.id, preset_dir, self.project_root + commands_to_register, manifest.id, preset_dir, self.project_root ) def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: @@ -555,6 +697,403 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non 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. @@ -624,7 +1163,7 @@ def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: try: manifest = ExtensionManifest(manifest_path) - except ValidationError: + except (ValidationError, TypeError, AttributeError): continue ext_root = ext_dir.resolve() @@ -707,6 +1246,7 @@ def _register_skills( 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): @@ -716,6 +1256,7 @@ def _register_skills( 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 @@ -732,6 +1273,12 @@ def _register_skills( 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."): @@ -759,6 +1306,13 @@ def _register_skills( 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, @@ -789,6 +1343,10 @@ def _register_skills( 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") @@ -816,6 +1374,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: 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" @@ -824,6 +1383,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: 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: @@ -877,6 +1437,10 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: 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 @@ -906,6 +1470,10 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: 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 @@ -952,22 +1520,82 @@ def install_from_directory( shutil.copytree(source_dir, dest_dir) - # Register command overrides with AI agents - registered_commands = self._register_commands(manifest, dest_dir) - - # Update corresponding skills when --ai-skills was previously used - registered_skills = self._register_skills(manifest, 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_commands, - "registered_skills": registered_skills, + "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( @@ -1043,6 +1671,28 @@ def remove(self, pack_id: str) -> bool: 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: @@ -1064,6 +1714,22 @@ def remove(self, pack_id: str) -> bool: 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]]: @@ -1178,6 +1844,22 @@ def _validate_catalog_url(self, url: str) -> None: "Catalog URL must be a valid URL with a host." ) + def _make_request(self, url: str): + """Build a urllib Request, adding auth headers when a provider matches. + + Delegates to :func:`specify_cli.authentication.http.build_request`. + """ + from specify_cli.authentication.http import build_request + return build_request(url) + + def _open_url(self, url: str, timeout: int = 10): + """Open a URL with provider-based auth, trying each configured provider. + + Delegates to :func:`specify_cli.authentication.http.open_url`. + """ + from specify_cli.authentication.http import open_url + return open_url(url, timeout) + def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]: """Load catalog stack configuration from a YAML file. @@ -1360,10 +2042,7 @@ def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = pass try: - import urllib.request - import urllib.error - - with urllib.request.urlopen(entry.url, timeout=10) as response: + with self._open_url(entry.url, timeout=10) as response: catalog_data = json.loads(response.read()) if ( @@ -1456,10 +2135,7 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: pass try: - import urllib.request - import urllib.error - - with urllib.request.urlopen(catalog_url, timeout=10) as response: + with self._open_url(catalog_url, timeout=10) as response: catalog_data = json.loads(response.read()) if ( @@ -1578,7 +2254,6 @@ def download_pack( Raises: PresetError: If pack not found or download fails """ - import urllib.request import urllib.error pack_info = self.get_pack_info(pack_id) @@ -1587,6 +2262,16 @@ def download_pack( 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( @@ -1620,7 +2305,7 @@ def download_pack( zip_path = target_dir / zip_filename try: - with urllib.request.urlopen(download_url, timeout=60) as response: + with self._open_url(download_url, timeout=60) as response: zip_data = response.read() zip_path.write_bytes(zip_data) @@ -1662,6 +2347,21 @@ def __init__(self, project_root: Path): 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. @@ -1705,10 +2405,24 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: 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. @@ -1717,6 +2431,8 @@ def resolve( 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 @@ -1745,7 +2461,7 @@ def resolve( return override # Priority 2: Installed presets (sorted by priority — lower number wins) - if self.presets_dir.exists(): + 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 @@ -1779,11 +2495,118 @@ def resolve( 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( @@ -1847,3 +2670,428 @@ def resolve_with_source( 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/shared_infra.py b/src/specify_cli/shared_infra.py new file mode 100644 index 0000000000..1e8be7b282 --- /dev/null +++ b/src/specify_cli/shared_infra.py @@ -0,0 +1,317 @@ +"""Shared Spec Kit infrastructure installation helpers.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from typing import Any + +from .integrations.base import IntegrationBase +from .integrations.manifest import IntegrationManifest + + +def load_speckit_manifest( + project_path: Path, + *, + version: str, + console: Any | None = None, +) -> IntegrationManifest: + """Load the shared infrastructure manifest, preserving existing entries.""" + manifest_path = project_path / ".specify" / "integrations" / "speckit.manifest.json" + if manifest_path.exists(): + try: + manifest = IntegrationManifest.load("speckit", project_path) + manifest.version = version + return manifest + except (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) as exc: + if console is not None: + console.print( + f"[yellow]Warning:[/yellow] Could not read shared infrastructure " + f"manifest at {manifest_path}: {exc}" + ) + console.print( + "A new shared manifest will be created; previously tracked " + "shared files may be treated as untracked." + ) + return IntegrationManifest("speckit", project_path, version=version) + + +def shared_templates_source( + *, + core_pack: Path | None, + repo_root: Path, +) -> Path: + """Return the bundled/source shared templates directory.""" + if core_pack and (core_pack / "templates").is_dir(): + return core_pack / "templates" + return repo_root / "templates" + + +def shared_scripts_source( + *, + core_pack: Path | None, + repo_root: Path, +) -> Path: + """Return the bundled/source shared scripts directory.""" + if core_pack and (core_pack / "scripts").is_dir(): + return core_pack / "scripts" + return repo_root / "scripts" + + +def _shared_destination_label(project_path: Path, dest: Path) -> str: + try: + return dest.relative_to(project_path).as_posix() + except ValueError: + return str(dest) + + +def _shared_relative_path(project_path: Path, dest: Path) -> Path: + try: + rel = dest.relative_to(project_path) + except ValueError: + label = _shared_destination_label(project_path, dest) + raise ValueError(f"Shared infrastructure path escapes project root: {label}") from None + + if rel.is_absolute() or ".." in rel.parts: + label = _shared_destination_label(project_path, dest) + raise ValueError(f"Shared infrastructure path escapes project root: {label}") + return rel + + +def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create: bool = True) -> None: + """Create a shared infra directory without following symlinked parents.""" + root = project_path.resolve() + rel = _shared_relative_path(project_path, directory) + current = project_path + + for part in rel.parts: + current = current / part + label = _shared_destination_label(project_path, current) + if current.is_symlink(): + raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}") + if current.exists(): + if not current.is_dir(): + raise ValueError(f"Shared infrastructure directory path is not a directory: {label}") + try: + current.resolve().relative_to(root) + except (OSError, ValueError): + raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None + continue + if not create: + raise ValueError(f"Shared infrastructure directory does not exist: {label}") + current.mkdir() + if current.is_symlink(): + raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}") + try: + current.resolve().relative_to(root) + except (OSError, ValueError): + raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None + + +def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None: + """Validate existing directory parents while allowing missing directories.""" + root = project_path.resolve() + rel = _shared_relative_path(project_path, directory) + current = project_path + + for part in rel.parts: + current = current / part + label = _shared_destination_label(project_path, current) + if current.is_symlink(): + raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}") + if not current.exists(): + continue + if not current.is_dir(): + raise ValueError(f"Shared infrastructure directory path is not a directory: {label}") + try: + current.resolve().relative_to(root) + except (OSError, ValueError): + raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None + + +def _ensure_safe_shared_destination( + project_path: Path, + dest: Path, + *, + parent_must_exist: bool = True, +) -> None: + """Refuse shared infra writes that would escape or follow symlinks.""" + root = project_path.resolve() + _shared_relative_path(project_path, dest) + if parent_must_exist: + _ensure_safe_shared_directory(project_path, dest.parent, create=False) + else: + _validate_safe_shared_directory(project_path, dest.parent) + label = _shared_destination_label(project_path, dest) + if dest.is_symlink(): + raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}") + + if dest.exists(): + try: + dest.resolve().relative_to(root) + except (OSError, ValueError): + raise ValueError(f"Shared infrastructure destination escapes project root: {label}") from None + + +def _write_shared_text(project_path: Path, dest: Path, content: str) -> None: + _write_shared_bytes(project_path, dest, content.encode("utf-8")) + + +def _write_shared_bytes( + project_path: Path, + dest: Path, + content: bytes, + *, + mode: int = 0o644, +) -> None: + _ensure_safe_shared_destination(project_path, dest) + fd, temp_name = tempfile.mkstemp(prefix=f".{dest.name}.", dir=dest.parent) + temp_path = Path(temp_name) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(content) + temp_path.chmod(mode) + _ensure_safe_shared_destination(project_path, dest) + os.replace(temp_path, dest) + finally: + if temp_path.exists(): + temp_path.unlink() + + +def refresh_shared_templates( + project_path: Path, + *, + version: str, + core_pack: Path | None, + repo_root: Path, + console: Any, + invoke_separator: str, + force: bool = False, +) -> None: + """Refresh default-sensitive shared templates without touching scripts.""" + templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root) + if not templates_src.is_dir(): + return + + manifest = load_speckit_manifest(project_path, version=version, console=console) + tracked_files = manifest.files + modified = set(manifest.check_modified()) + skipped_files: list[str] = [] + planned_updates: list[tuple[Path, str, str]] = [] + + dest_templates = project_path / ".specify" / "templates" + _ensure_safe_shared_directory(project_path, dest_templates) + for src in templates_src.iterdir(): + if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."): + continue + + dst = dest_templates / src.name + _ensure_safe_shared_destination(project_path, dst) + rel = dst.relative_to(project_path).as_posix() + if dst.exists() and not force: + if rel not in tracked_files or rel in modified: + skipped_files.append(rel) + continue + + content = src.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs(content, invoke_separator) + planned_updates.append((dst, rel, content)) + + for dst, rel, content in planned_updates: + _write_shared_text(project_path, dst, content) + manifest.record_existing(rel) + + manifest.save() + + if skipped_files: + console.print( + f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:" + ) + for rel in skipped_files: + console.print(f" {rel}") + + +def install_shared_infra( + project_path: Path, + script_type: str, + *, + version: str, + core_pack: Path | None, + repo_root: Path, + console: Any, + force: bool = False, + invoke_separator: str = ".", +) -> bool: + """Install shared scripts and templates into *project_path*.""" + manifest = load_speckit_manifest(project_path, version=version, console=console) + skipped_files: list[str] = [] + planned_copies: list[tuple[Path, str, bytes, int]] = [] + planned_templates: list[tuple[Path, str, str]] = [] + + scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root) + if scripts_src.is_dir(): + dest_scripts = project_path / ".specify" / "scripts" + _ensure_safe_shared_directory(project_path, dest_scripts) + 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 + _ensure_safe_shared_directory(project_path, dest_variant) + for src_path in variant_src.rglob("*"): + if not src_path.is_file(): + continue + + rel_path = src_path.relative_to(variant_src) + dst_path = dest_variant / rel_path + _ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False) + if dst_path.exists() and not force: + skipped_files.append(dst_path.relative_to(project_path).as_posix()) + continue + + _ensure_safe_shared_directory(project_path, dst_path.parent) + rel = dst_path.relative_to(project_path).as_posix() + planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777)) + + templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root) + if templates_src.is_dir(): + dest_templates = project_path / ".specify" / "templates" + _ensure_safe_shared_directory(project_path, dest_templates) + for src in templates_src.iterdir(): + if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."): + continue + + dst = dest_templates / src.name + _ensure_safe_shared_destination(project_path, dst) + if dst.exists() and not force: + skipped_files.append(dst.relative_to(project_path).as_posix()) + continue + + content = src.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs(content, invoke_separator) + rel = dst.relative_to(project_path).as_posix() + planned_templates.append((dst, rel, content)) + + for dst_path, rel, content, mode in planned_copies: + _ensure_safe_shared_directory(project_path, dst_path.parent) + _write_shared_bytes(project_path, dst_path, content, mode=mode) + manifest.record_existing(rel) + + for dst, rel, content in planned_templates: + _write_shared_text(project_path, dst, content) + 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 path in skipped_files: + console.print(f" {path}") + console.print( + "To refresh shared infrastructure, run " + "[cyan]specify init --here --force[/cyan] or " + "[cyan]specify integration upgrade --force[/cyan]." + ) + + manifest.save() + return True 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..213b443e3d --- /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 specify_cli.authentication.http import open_url as _open_url + + 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 _open_url(entry.url, timeout=30) as resp: + _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, agent-specific file +**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file ## Key rules -- Use absolute paths +- 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 a81b8f12f1..cafa32f4e2 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -8,9 +8,6 @@ handoffs: agent: speckit.clarify prompt: Clarify specification requirements send: true -scripts: - sh: scripts/bash/create-new-feature.sh "{ARGS}" - ps: scripts/powershell/create-new-feature.ps1 "{ARGS}" --- ## User Input @@ -57,11 +54,11 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided an empty command. +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. Given that feature description, do this: -1. **Generate a concise short name** (2-4 words) for the branch: +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") @@ -73,30 +70,47 @@ Given that feature description, do this: - "Create a dashboard for analytics" → "analytics-dashboard" - "Fix payment processing timeout bug" → "fix-payment-timeout" -2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically: +2. **Branch creation** (optional, via hook): - **Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value. - - If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation - - If `"sequential"` or absent, do not add any extra flag (default behavior) + 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. - - Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"` - - Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"` - - PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"` - - PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"` + 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). - **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 is provided in the terminal as output - always refer to it to get the actual content you're looking for - - The JSON output will contain BRANCH_NAME and SPEC_FILE paths - - 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") +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/` -3. Load `templates/spec-template.md` to understand required sections. + **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. Follow this execution flow: +4. Load `templates/spec-template.md` to understand required sections. - 1. Parse user description from Input +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 @@ -120,11 +134,11 @@ Given that feature description, do this: 7. Identify Key Entities (if data involved) 8. Return: SUCCESS (spec ready for planning) -5. 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. +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. -6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: +7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: - a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items: + 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] @@ -160,7 +174,7 @@ Given that feature description, do this: ## Notes - - Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` + - 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: @@ -169,7 +183,7 @@ Given that feature description, do this: c. **Handle Validation Results**: - - **If all items pass**: Mark checklist complete and proceed to step 7 + - **If all items pass**: Mark checklist complete and proceed to step 8 - **If items fail (excluding [NEEDS CLARIFICATION])**: 1. List the failing items and specific issues @@ -214,9 +228,13 @@ Given that feature description, do this: d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status -7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). +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__`) -8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. +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. @@ -245,7 +263,7 @@ Given that feature description, do this: ``` - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently -**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. +**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 diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 4e204abc1b..e5af6793b6 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -10,8 +10,8 @@ handoffs: prompt: Start the implementation in phases send: true scripts: - sh: scripts/bash/check-prerequisites.sh --json - ps: scripts/powershell/check-prerequisites.ps1 -Json + sh: scripts/bash/setup-tasks.sh --json + ps: scripts/powershell/setup-tasks.ps1 -Json --- ## User Input @@ -58,7 +58,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -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"). +1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). 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"). 2. **Load design documents**: Read from FEATURE_DIR: - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities) @@ -76,7 +76,7 @@ You **MUST** consider the user input before proceeding (if not empty). - 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: +4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with: - Correct feature name from plan.md - Phase 1: Setup tasks (project initialization) - Phase 2: Foundational tasks (blocking prerequisites for all user stories) diff --git a/templates/plan-template.md b/templates/plan-template.md index 5a2fafebe3..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/plan-template.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 @@ -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/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/auth_helpers.py b/tests/auth_helpers.py new file mode 100644 index 0000000000..babc43e406 --- /dev/null +++ b/tests/auth_helpers.py @@ -0,0 +1,21 @@ +"""Shared test helpers for authentication config injection.""" + +from __future__ import annotations + +from specify_cli.authentication.config import AuthConfigEntry + + +def make_github_auth_entry(token_env: str = "GH_TOKEN") -> AuthConfigEntry: + """Build a GitHub ``AuthConfigEntry`` for testing.""" + return AuthConfigEntry( + hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"), + provider="github", + auth="bearer", + token_env=token_env, + ) + + +def inject_github_config(monkeypatch, token_env: str = "GH_TOKEN") -> None: + """Inject a GitHub auth.json config entry into the auth HTTP module.""" + from specify_cli.authentication import http as _auth_http + monkeypatch.setattr(_auth_http, "_config_override", [make_github_auth_entry(token_env)]) diff --git a/tests/conftest.py b/tests/conftest.py index 4387c9ac8f..0e568a1e2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,83 @@ """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) + + +# --------------------------------------------------------------------------- +# Auth config isolation — prevents tests from reading ~/.specify/auth.json +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _isolate_auth_config(monkeypatch): + """Ensure no test reads the real ~/.specify/auth.json.""" + from specify_cli.authentication import http as _auth_http + monkeypatch.setattr(_auth_http, "_config_override", []) + # Also clear the per-process cache so tests that unset _config_override + # won't see a previously cached real-file result. + monkeypatch.setattr(_auth_http, "_config_cache", None) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 721bd999f2..c4f986d177 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -14,10 +14,13 @@ 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" @@ -211,6 +214,7 @@ def test_bundled_extension_locator(self): # ── 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.""" @@ -269,6 +273,7 @@ def test_skips_if_already_git_repo(self, tmp_path: Path): # ── 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.""" @@ -280,7 +285,6 @@ def test_creates_branch_sequential(self, tmp_path: Path): assert result.returncode == 0, result.stderr data = json.loads(result.stdout) assert data["BRANCH_NAME"] == "001-user-auth" - assert "SPEC_FILE" in data assert data["FEATURE_NUM"] == "001" def test_creates_branch_timestamp(self, tmp_path: Path): @@ -294,18 +298,6 @@ def test_creates_branch_timestamp(self, tmp_path: Path): data = json.loads(result.stdout) assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) - def test_creates_spec_dir(self, tmp_path: Path): - """create-new-feature.sh creates specs directory and spec.md.""" - project = _setup_project(tmp_path) - result = _run_bash( - "create-new-feature.sh", project, - "--json", "--short-name", "test-feat", "Test feature", - ) - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout) - spec_file = Path(data["SPEC_FILE"]) - assert spec_file.exists(), f"spec.md not created at {spec_file}" - def test_increments_from_existing_specs(self, tmp_path: Path): """Sequential numbering increments past existing spec directories.""" project = _setup_project(tmp_path) @@ -321,7 +313,7 @@ def test_increments_from_existing_specs(self, tmp_path: Path): assert data["FEATURE_NUM"] == "003" def test_no_git_graceful_degradation(self, tmp_path: Path): - """create-new-feature.sh works without git (creates spec dir only).""" + """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, @@ -330,8 +322,8 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): assert result.returncode == 0, result.stderr assert "Warning" in result.stderr data = json.loads(result.stdout) - spec_file = Path(data["SPEC_FILE"]) - assert spec_file.exists() + 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.""" @@ -382,12 +374,14 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): 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 Path(data["SPEC_FILE"]).exists() + 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.""" @@ -503,6 +497,34 @@ def test_requires_event_name_argument(self, tmp_path: 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: @@ -535,10 +557,189 @@ def test_enabled_per_command(self, tmp_path: Path): ) 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.""" @@ -599,3 +800,40 @@ def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path): 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/integrations/test_base.py b/tests/integrations/test_base.py index 03b5eb3068..3b244943b4 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -6,6 +6,7 @@ IntegrationBase, IntegrationOption, MarkdownIntegration, + SkillsIntegration, ) from specify_cli.integrations.manifest import IntegrationManifest from .conftest import StubIntegration @@ -167,3 +168,130 @@ def test_setup_copies_shared_templates(self, tmp_path): 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 index 945ce6ac62..c04975ce66 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -1,8 +1,26 @@ """Tests for --integration flag on specify init (CLI-level).""" +import io import json import os +import pytest +import yaml +from rich.console import Console + +from tests.conftest import strip_ansi + + +class _NoopConsole: + def print(self, *args, **kwargs): + pass + + +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): @@ -46,18 +64,46 @@ def test_integration_copilot_creates_files(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "copilot" - assert "scripts" in data - assert "update-context" in data["scripts"] 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() - assert (project / ".specify" / "integrations" / "copilot" / "scripts" / "update-context.sh").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_noninteractive_init_defaults_to_copilot(self, tmp_path, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + import specify_cli + + def fail_select(*_args, **_kwargs): + raise AssertionError("non-interactive init should not open the integration picker") + + monkeypatch.setattr(specify_cli, "select_with_arrows", fail_select) + + runner = CliRunner() + project = tmp_path / "noninteractive" + result = runner.invoke(app, [ + "init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + + assert result.exit_code == 0, result.output + assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION + def test_ai_copilot_auto_promotes(self, tmp_path): from typer.testing import CliRunner from specify_cli import app @@ -75,6 +121,59 @@ def test_ai_copilot_auto_promotes(self, tmp_path): 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 "0.10.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 @@ -105,13 +204,43 @@ def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): 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(self, tmp_path): - """Pre-existing shared files are not overwritten by _install_shared_infra.""" - from typer.testing import CliRunner - from specify_cli import app + 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" @@ -125,6 +254,371 @@ def test_shared_infra_skips_existing_files(self, tmp_path): 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_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys): + """Invalid shared manifests warn before falling back to a new manifest.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "bad-shared-manifest-test" + project.mkdir() + integrations_dir = project / ".specify" / "integrations" + integrations_dir.mkdir(parents=True) + manifest_path = integrations_dir / "speckit.manifest.json" + manifest_path.write_text("{not json", encoding="utf-8") + + _install_shared_infra(project, "sh") + + captured = capsys.readouterr() + assert "Could not read shared infrastructure manifest" in captured.out + assert "A new shared manifest will be created" in captured.out + + def test_shared_infra_warns_when_manifest_cannot_be_decoded(self, tmp_path, capsys): + """Non-UTF-8 shared manifests warn before falling back to a new manifest.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "bad-shared-manifest-encoding-test" + project.mkdir() + integrations_dir = project / ".specify" / "integrations" + integrations_dir.mkdir(parents=True) + manifest_path = integrations_dir / "speckit.manifest.json" + manifest_path.write_bytes(b"\xff\xfe\x00") + + _install_shared_infra(project, "sh") + + captured = capsys.readouterr() + assert "Could not read shared infrastructure manifest" in captured.out + assert "A new shared manifest will be created" in captured.out + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path): + """Shared script refreshes must not follow destination symlinks.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "symlink-script-test" + project.mkdir() + (project / ".specify").mkdir() + + outside = tmp_path / "outside-script.sh" + outside.write_text("# outside\n", encoding="utf-8") + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + os.symlink(outside, scripts_dir / "common.sh") + + with pytest.raises(ValueError, match="Refusing to overwrite symlinked"): + _install_shared_infra(project, "sh", force=True) + + assert outside.read_text(encoding="utf-8") == "# outside\n" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path): + """Shared template installs must not follow destination symlinks.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "symlink-template-test" + project.mkdir() + (project / ".specify").mkdir() + + outside = tmp_path / "outside-template.md" + outside.write_text("# outside\n", encoding="utf-8") + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + os.symlink(outside, templates_dir / "plan-template.md") + + with pytest.raises(ValueError, match="Refusing to overwrite symlinked"): + _install_shared_infra(project, "sh", force=True) + + assert outside.read_text(encoding="utf-8") == "# outside\n" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_template_refresh_refuses_symlinked_destination(self, tmp_path): + """Template-only refreshes must not follow destination symlinks.""" + from specify_cli import _refresh_shared_templates + + project = tmp_path / "symlink-refresh-test" + project.mkdir() + (project / ".specify").mkdir() + + outside = tmp_path / "outside-refresh.md" + outside.write_text("# outside\n", encoding="utf-8") + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + os.symlink(outside, templates_dir / "plan-template.md") + + with pytest.raises(ValueError, match="Refusing to overwrite symlinked"): + _refresh_shared_templates(project, invoke_separator=".", force=True) + + assert outside.read_text(encoding="utf-8") == "# outside\n" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path): + """Shared infra directory creation must not follow a symlinked .specify.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "symlink-dir-test" + project.mkdir() + outside = tmp_path / "outside-specify" + outside.mkdir() + os.symlink(outside, project / ".specify") + + with pytest.raises(ValueError, match="symlinked shared infrastructure directory"): + _install_shared_infra(project, "sh", force=True) + + assert not (outside / "scripts").exists() + assert not (outside / "templates").exists() + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_infra_refuses_symlinked_shared_manifest(self, tmp_path): + """Shared infra manifest saves must not follow destination symlinks.""" + from specify_cli.shared_infra import install_shared_infra + + project = tmp_path / "symlink-shared-manifest-test" + project.mkdir() + integrations_dir = project / ".specify" / "integrations" + integrations_dir.mkdir(parents=True) + + outside = tmp_path / "outside-manifest.json" + outside.write_text("# outside\n", encoding="utf-8") + os.symlink(outside, integrations_dir / "speckit.manifest.json") + + core_pack = tmp_path / "core-pack" + templates_src = core_pack / "templates" + templates_src.mkdir(parents=True) + (templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8") + + with pytest.raises(ValueError, match="symlinked integration manifest"): + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + force=True, + ) + + assert outside.read_text(encoding="utf-8") == "# outside\n" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_template_refresh_preflights_before_writing(self, tmp_path): + """Template refresh validates all destinations before writing any file.""" + from specify_cli.shared_infra import refresh_shared_templates + + project = tmp_path / "preflight-refresh-test" + project.mkdir() + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + + core_pack = tmp_path / "core-pack" + templates_src = core_pack / "templates" + templates_src.mkdir(parents=True) + (templates_src / "a-template.md").write_text("# new a\n", encoding="utf-8") + (templates_src / "z-template.md").write_text("# new z\n", encoding="utf-8") + + existing = templates_dir / "a-template.md" + existing.write_text("# old a\n", encoding="utf-8") + outside = tmp_path / "outside-z.md" + outside.write_text("# outside\n", encoding="utf-8") + os.symlink(outside, templates_dir / "z-template.md") + + with pytest.raises(ValueError, match="Refusing to overwrite symlinked"): + refresh_shared_templates( + project, + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + invoke_separator=".", + force=True, + ) + + assert existing.read_text(encoding="utf-8") == "# old a\n" + assert outside.read_text(encoding="utf-8") == "# outside\n" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_infra_install_preflights_before_writing(self, tmp_path): + """Full shared infra installs validate destinations before writing any file.""" + from specify_cli.shared_infra import install_shared_infra + + project = tmp_path / "preflight-install-test" + project.mkdir() + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + + core_pack = tmp_path / "core-pack" + scripts_src = core_pack / "scripts" / "bash" + scripts_src.mkdir(parents=True) + (scripts_src / "a.sh").write_text("# new a\n", encoding="utf-8") + (scripts_src / "z.sh").write_text("# new z\n", encoding="utf-8") + + existing = scripts_dir / "a.sh" + existing.write_text("# old a\n", encoding="utf-8") + outside = tmp_path / "outside-z.sh" + outside.write_text("# outside\n", encoding="utf-8") + os.symlink(outside, scripts_dir / "z.sh") + + with pytest.raises(ValueError, match="Refusing to overwrite symlinked"): + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + force=True, + ) + + assert existing.read_text(encoding="utf-8") == "# old a\n" + assert outside.read_text(encoding="utf-8") == "# outside\n" + + def test_shared_infra_install_supports_nested_script_sources(self, tmp_path): + """Nested script source files create safe destination parents at write time.""" + from specify_cli.shared_infra import install_shared_infra + + project = tmp_path / "nested-script-install-test" + project.mkdir() + + core_pack = tmp_path / "core-pack" + nested_src = core_pack / "scripts" / "bash" / "nested" + nested_src.mkdir(parents=True) + (nested_src / "deep.sh").write_text("# nested\n", encoding="utf-8") + + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + force=True, + ) + + nested_dest = project / ".specify" / "scripts" / "bash" / "nested" / "deep.sh" + assert nested_dest.read_text(encoding="utf-8") == "# nested\n" + + def test_shared_infra_skip_warning_uses_posix_paths(self, tmp_path): + """Skipped shared infra paths are reported consistently across platforms.""" + from specify_cli.shared_infra import install_shared_infra + + project = tmp_path / "posix-skip-warning-test" + project.mkdir() + nested_dest = project / ".specify" / "scripts" / "bash" / "nested" + nested_dest.mkdir(parents=True) + (nested_dest / "deep.sh").write_text("# existing script\n", encoding="utf-8") + + templates_dest = project / ".specify" / "templates" + templates_dest.mkdir(parents=True) + (templates_dest / "plan-template.md").write_text("# existing template\n", encoding="utf-8") + + core_pack = tmp_path / "core-pack" + nested_src = core_pack / "scripts" / "bash" / "nested" + nested_src.mkdir(parents=True) + (nested_src / "deep.sh").write_text("# bundled script\n", encoding="utf-8") + + templates_src = core_pack / "templates" + templates_src.mkdir(parents=True) + (templates_src / "plan-template.md").write_text("# bundled template\n", encoding="utf-8") + + buffer = io.StringIO() + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=Console(file=buffer, force_terminal=False, width=120), + force=False, + ) + + output = buffer.getvalue() + assert ".specify/scripts/bash/nested/deep.sh" in output + assert ".specify/templates/plan-template.md" in output + + @pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not stable on Windows") + def test_shared_template_writes_are_not_world_writable(self, tmp_path): + """Shared template writes use a safe default mode instead of chmod 666.""" + from specify_cli.shared_infra import install_shared_infra + + project = tmp_path / "template-mode-test" + project.mkdir() + + core_pack = tmp_path / "core-pack" + templates_src = core_pack / "templates" + templates_src.mkdir(parents=True) + (templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8") + + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + force=True, + ) + + written = project / ".specify" / "templates" / "plan-template.md" + assert written.stat().st_mode & 0o777 == 0o644 + + 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) @@ -139,11 +633,1019 @@ def test_shared_infra_skips_existing_files(self, tmp_path): 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) - # User's files should be preserved + assert result.exit_code == 0 + # Without --force, custom file should be preserved 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 + # Warning about skipped files should appear + assert "not updated" in result.output - # Other shared files should still be installed - assert (scripts_dir / "setup-plan.sh").exists() - assert (templates_dir / "plan-template.md").exists() + +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_no_git_emits_deprecation_warning(self, tmp_path): + """Using --no-git emits a visible deprecation warning.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "no-git-warn" + 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) + + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "--no-git" in normalized_output + assert "deprecated" in normalized_output + assert "0.10.0" in normalized_output + assert "specify extension" in normalized_output + assert "will be removed" in normalized_output + assert "git extension will no longer be enabled by default" in normalized_output + + def test_default_git_auto_enable_emits_notice(self, tmp_path): + """Default git auto-enable emits notice about the v0.10.0 opt-in change.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "git-default-notice" + 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) + + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + # Check for key message components (notice may have box-drawing chars) + assert "git extension is currently enabled by default" in normalized_output + assert "v0.10.0" in normalized_output + assert "explicit opt-in" in normalized_output + assert "specify extension add git" in normalized_output + + 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 + + +class TestIntegrationCatalogDiscoveryCLI: + """End-to-end CLI tests for `integration search`, `info`, and `catalog …`. + + All tests patch `IntegrationCatalog._get_merged_integrations` so no network + or on-disk cache is touched. Adds #2344 coverage without affecting any + existing integration install/switch/uninstall/upgrade behavior. + """ + + FAKE_INTEGRATIONS = [ + { + "id": "acme-coder", + "name": "Acme Coder", + "version": "2.0.0", + "description": "Community integration for Acme Coder", + "author": "acme-org", + "tags": ["cli", "acme"], + "_catalog_name": "community", + "_install_allowed": False, + }, + { + "id": "stellar-agent", + "name": "Stellar Agent", + "version": "1.3.0", + "description": "First-party Stellar agent integration", + "author": "stellar-labs", + "tags": ["ide"], + "_catalog_name": "default", + "_install_allowed": True, + }, + ] + + def _make_project(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + return project + + def _patch_catalog(self, monkeypatch, integrations=None): + """Return a stubbed `_get_merged_integrations` that yields *integrations*.""" + from specify_cli.integrations.catalog import IntegrationCatalog + + data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS) + + def fake_merged(self, force_refresh=False): + return data + + monkeypatch.setattr(IntegrationCatalog, "_get_merged_integrations", fake_merged) + + def _invoke(self, argv, cwd): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + old = os.getcwd() + try: + os.chdir(cwd) + return runner.invoke(app, argv, catch_exceptions=False) + finally: + os.chdir(old) + + # -- Project guard ----------------------------------------------------- + + def test_search_requires_specify_project(self, tmp_path): + project = tmp_path / "bare" + project.mkdir() + result = self._invoke(["integration", "search"], project) + assert result.exit_code == 1 + assert "Not a spec-kit project" in result.output + + def test_catalog_list_requires_specify_project(self, tmp_path): + project = tmp_path / "bare" + project.mkdir() + result = self._invoke(["integration", "catalog", "list"], project) + assert result.exit_code == 1 + assert "Not a spec-kit project" in result.output + + def test_primary_integration_commands_require_specify_project(self, tmp_path): + project = tmp_path / "bare" + project.mkdir() + commands = [ + ["integration", "list"], + ["integration", "install", "codex"], + ["integration", "use", "codex"], + ["integration", "uninstall"], + ["integration", "switch", "codex"], + ["integration", "upgrade"], + ] + + for command in commands: + result = self._invoke(command, project) + failure_context = ( + f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}" + ) + assert result.exit_code == 1, failure_context + assert "Not a spec-kit project" in result.output, failure_context + + def test_integration_commands_require_specify_directory(self, tmp_path): + project = tmp_path / "bad" + project.mkdir() + (project / ".specify").write_text("not a directory") + + commands = [ + ["integration", "list"], + ["integration", "use", "codex"], + ] + + for command in commands: + result = self._invoke(command, project) + assert result.exit_code == 1, result.output + assert "Not a spec-kit project" in result.output + + def test_project_scoped_commands_require_specify_directory(self, tmp_path): + project = tmp_path / "bad-feature-commands" + project.mkdir() + (project / ".specify").write_text("not a directory") + + commands = [ + ["preset", "list"], + ["preset", "add", "demo"], + ["preset", "remove", "demo"], + ["preset", "search"], + ["preset", "resolve", "spec-template"], + ["preset", "info", "demo"], + ["preset", "set-priority", "demo", "5"], + ["preset", "enable", "demo"], + ["preset", "disable", "demo"], + ["preset", "catalog", "list"], + ["preset", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"], + ["preset", "catalog", "remove", "demo"], + ["extension", "list"], + ["extension", "add", "demo"], + ["extension", "remove", "demo"], + ["extension", "search"], + ["extension", "info", "demo"], + ["extension", "update", "demo"], + ["extension", "enable", "demo"], + ["extension", "disable", "demo"], + ["extension", "set-priority", "demo", "5"], + ["extension", "catalog", "list"], + ["extension", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"], + ["extension", "catalog", "remove", "demo"], + ["workflow", "run", "demo"], + ["workflow", "resume", "demo"], + ["workflow", "status"], + ["workflow", "list"], + ["workflow", "add", "demo"], + ["workflow", "remove", "demo"], + ["workflow", "search"], + ["workflow", "info", "demo"], + ["workflow", "catalog", "list"], + ["workflow", "catalog", "add", "https://example.com/catalog.yml"], + ["workflow", "catalog", "remove", "0"], + ] + + for command in commands: + result = self._invoke(command, project) + failure_context = ( + f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}" + ) + assert result.exit_code == 1, failure_context + assert "Not a spec-kit project" in result.output, failure_context + + def test_catalog_config_output_uses_posix_paths(self, tmp_path): + project = self._make_project(tmp_path) + + preset_add = self._invoke([ + "preset", "catalog", "add", + "https://example.com/preset-catalog.yml", + "--name", "demo-presets", + ], project) + assert preset_add.exit_code == 0, preset_add.output + assert "Config saved to .specify/preset-catalogs.yml" in preset_add.output + + preset_list = self._invoke(["preset", "catalog", "list"], project) + assert preset_list.exit_code == 0, preset_list.output + assert "Config: .specify/preset-catalogs.yml" in preset_list.output + + extension_add = self._invoke([ + "extension", "catalog", "add", + "https://example.com/extension-catalog.yml", + "--name", "demo-extensions", + ], project) + assert extension_add.exit_code == 0, extension_add.output + assert "Config saved to .specify/extension-catalogs.yml" in extension_add.output + + extension_list = self._invoke(["extension", "catalog", "list"], project) + assert extension_list.exit_code == 0, extension_list.output + assert "Config: .specify/extension-catalogs.yml" in extension_list.output + + # -- search ------------------------------------------------------------ + + def test_search_lists_all(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "Found 2 integration(s)" in result.output + assert "acme-coder" in result.output + assert "stellar-agent" in result.output + assert "specify integration install stellar-agent" not in normalized_output + assert "Only built-in integration IDs can be installed" in normalized_output + + def test_search_validates_integration_json_before_catalog_lookup( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + (project / ".specify" / "integration.json").write_text( + "{bad json\n", encoding="utf-8" + ) + + from specify_cli.integrations.catalog import IntegrationCatalog + + def fail_search(self, **kwargs): + raise AssertionError("catalog search should not be called") + + monkeypatch.setattr(IntegrationCatalog, "search", fail_search) + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1 + assert "contains invalid JSON" in normalized_output + assert "integration.json" in normalized_output + + def test_search_filters_by_tag(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke(["integration", "search", "--tag", "acme"], project) + assert result.exit_code == 0, result.output + assert "Found 1 integration(s)" in result.output + assert "acme-coder" in result.output + assert "stellar-agent" not in result.output + + def test_search_filters_by_author(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "search", "--author", "stellar-labs"], project + ) + assert result.exit_code == 0, result.output + assert "Found 1 integration(s)" in result.output + assert "stellar-agent" in result.output + + def test_search_no_match_hint(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "search", "--tag", "nope"], project + ) + assert result.exit_code == 0, result.output + assert "No integrations found" in result.output + assert "specify integration search" in result.output + + def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke(["integration", "search", "acme"], project) + assert result.exit_code == 0, result.output + # acme-coder is flagged _install_allowed=False, so we should warn + assert "Not directly installable" in result.output + + # -- info -------------------------------------------------------------- + + def test_info_found(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "info", "stellar-agent"], project + ) + assert result.exit_code == 0, result.output + assert "Stellar Agent" in result.output + assert "stellar-agent" in result.output + assert "v1.3.0" in result.output + + def test_info_not_found(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "info", "does-not-exist"], project + ) + assert result.exit_code == 1 + assert "not found" in result.output + + def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + # Empty catalog, but copilot is a registered built-in. + self._patch_catalog(monkeypatch, integrations=[]) + result = self._invoke(["integration", "info", "copilot"], project) + assert result.exit_code == 0, result.output + assert "Built-in integration" in result.output + + # -- validation vs network guidance ------------------------------------ + + def test_search_local_config_error_shows_local_config_tip( + self, tmp_path, monkeypatch + ): + """`integration search` must point at .specify/integration-catalogs.yml + for local-config errors (not the generic 'temporarily unavailable').""" + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + # Corrupt YAML to drive _load_catalog_config -> IntegrationValidationError. + cfg = project / ".specify" / "integration-catalogs.yml" + invalid_yaml = "catalogs:\n - [bad\n" + cfg.write_text(invalid_yaml, encoding="utf-8") + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "configuration file path shown above" in normalized_output + assert ".specify/integration-catalogs.yml" in normalized_output + assert "~/.specify/integration-catalogs.yml" in normalized_output + assert "temporarily unavailable" not in normalized_output + + def test_search_invalid_env_catalog_url_shows_env_tip( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "http://insecure.example.com/catalog.json", + ) + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "SPECKIT_INTEGRATION_CATALOG_URL environment variable" in normalized_output + assert "unset it to use the configured catalog files" in normalized_output + assert ".specify/integration-catalogs.yml" in normalized_output + assert "~/.specify/integration-catalogs.yml" in normalized_output + assert "temporarily unavailable" not in normalized_output + + def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", " ") + + from specify_cli.integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + ) + + def fail_search(self, **kwargs): + raise IntegrationCatalogError("catalog offline") + + monkeypatch.setattr(IntegrationCatalog, "search", fail_search) + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "temporarily unavailable" in normalized_output + assert ( + "SPECKIT_INTEGRATION_CATALOG_URL environment variable" + not in normalized_output + ) + + def test_info_unknown_with_local_config_error_shows_local_config_tip( + self, tmp_path, monkeypatch + ): + """`integration info ` falls back to the catalog-error branch + and must show local-config guidance, not 'Try again when online'.""" + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + cfg = project / ".specify" / "integration-catalogs.yml" + invalid_yaml = "catalogs:\n - [bad\n" + cfg.write_text(invalid_yaml, encoding="utf-8") + + result = self._invoke( + ["integration", "info", "definitely-not-real"], project + ) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "configuration file path shown above" in normalized_output + assert ".specify/integration-catalogs.yml" in normalized_output + assert "~/.specify/integration-catalogs.yml" in normalized_output + assert "Try again when online" not in normalized_output + + def test_info_unknown_with_invalid_env_catalog_url_shows_env_tip( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "http://insecure.example.com/catalog.json", + ) + + result = self._invoke( + ["integration", "info", "definitely-not-real"], project + ) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "SPECKIT_INTEGRATION_CATALOG_URL" in normalized_output + assert "unset it to use the configured catalog files" in normalized_output + assert "Try again when online" not in normalized_output + + # -- catalog list / add / remove --------------------------------------- + + def test_catalog_list_shows_builtin_defaults(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + result = self._invoke(["integration", "catalog", "list"], project) + assert result.exit_code == 0, result.output + assert "Integration Catalog Sources" in result.output + assert "No project-level catalog sources configured" in result.output + assert "Active catalog sources" in result.output + assert "non-removable" in result.output + assert "default" in result.output + assert "community" in result.output + # Built-in defaults are active, but not removable project entries. + assert "[0]" not in result.output + assert "[1]" not in result.output + + def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + + add_result = self._invoke( + [ + "integration", + "catalog", + "add", + "https://new.example.com/catalog.json", + "--name", + "mine", + ], + project, + ) + assert add_result.exit_code == 0, add_result.output + assert "Catalog source added" in add_result.output + + cfg_path = project / ".specify" / "integration-catalogs.yml" + assert cfg_path.exists() + + list_result = self._invoke(["integration", "catalog", "list"], project) + assert list_result.exit_code == 0, list_result.output + assert "Project catalog sources" in list_result.output + assert "[0]" in list_result.output + assert "mine" in list_result.output + assert "default" not in list_result.output + assert "community" not in list_result.output + + remove_result = self._invoke( + ["integration", "catalog", "remove", "0"], project + ) + assert remove_result.exit_code == 0, remove_result.output + assert "'mine' removed" in remove_result.output + + def test_catalog_list_normalizes_blank_project_catalog_names( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + cfg_path = project / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://null-name.example.com/catalog.json", + "name": None, + }, + { + "url": "https://blank-name.example.com/catalog.json", + "name": " ", + }, + ] + } + ), + encoding="utf-8", + ) + + result = self._invoke(["integration", "catalog", "list"], project) + normalized_output = _normalize_cli_output(result.output) + + assert result.exit_code == 0, result.output + assert "[0] catalog-1" in normalized_output + assert "[1] catalog-2" in normalized_output + assert "None" not in normalized_output + + def test_catalog_list_env_override_supersedes_project_config( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "https://env.example.com/catalog.json", + ) + cfg_path = project / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://project.example.com/catalog.json", + "name": "project", + "priority": 1, + } + ] + } + ), + encoding="utf-8", + ) + + result = self._invoke(["integration", "catalog", "list"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "SPECKIT_INTEGRATION_CATALOG_URL is set" in normalized_output + assert "supersedes configured catalog files" in normalized_output + assert "non-removable" in normalized_output + assert "https://env.example.com/catalog.json" in normalized_output + assert "https://project.example.com/catalog.json" not in normalized_output + assert "[0]" not in normalized_output + + def test_catalog_add_strips_whitespace_in_success_output_and_storage( + self, tmp_path, monkeypatch + ): + """Surrounding whitespace in the URL must not appear in the success + message or be persisted to the YAML config.""" + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + + padded_url = " https://padded.example.com/catalog.json " + clean_url = "https://padded.example.com/catalog.json" + + add_result = self._invoke( + [ + "integration", + "catalog", + "add", + padded_url, + "--name", + "padded", + ], + project, + ) + assert add_result.exit_code == 0, add_result.output + assert clean_url in add_result.output + assert padded_url not in add_result.output + + cfg_path = project / ".specify" / "integration-catalogs.yml" + import yaml as _yaml + data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + urls = [c["url"] for c in data["catalogs"]] + assert clean_url in urls + assert padded_url not in urls + + def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + result = self._invoke( + [ + "integration", + "catalog", + "add", + "http://insecure.example.com/catalog.json", + ], + project, + ) + assert result.exit_code == 1 + assert "HTTPS" in result.output + + def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + url = "https://dup.example.com/catalog.json" + first = self._invoke( + ["integration", "catalog", "add", url], project + ) + assert first.exit_code == 0, first.output + second = self._invoke( + ["integration", "catalog", "add", url], project + ) + assert second.exit_code == 1 + assert "already configured" in second.output + + def test_catalog_remove_out_of_range(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + # Need a config file for remove to attempt an index lookup + self._invoke( + [ + "integration", + "catalog", + "add", + "https://only.example.com/catalog.json", + ], + project, + ) + result = self._invoke( + ["integration", "catalog", "remove", "9"], project + ) + assert result.exit_code == 1 + assert "out of range" in result.output + + def test_catalog_remove_without_config(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + result = self._invoke( + ["integration", "catalog", "remove", "0"], project + ) + assert result.exit_code == 1 + assert "No catalog config" in result.output + + def test_catalog_remove_final_entry_restores_defaults( + self, tmp_path, monkeypatch + ): + """End-to-end: add → remove-last-entry → list should not error. + + Regression for the flow where a user adds a catalog, removes it, then + runs any follow-up integration command. Without the fix the config + file would be left as `catalogs: []` and every subsequent + `integration` call would fail with "contains no 'catalogs' entries". + """ + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + + add = self._invoke( + [ + "integration", + "catalog", + "add", + "https://only.example.com/catalog.json", + "--name", + "only", + ], + project, + ) + assert add.exit_code == 0, add.output + + remove = self._invoke( + ["integration", "catalog", "remove", "0"], project + ) + assert remove.exit_code == 0, remove.output + assert "'only' removed" in remove.output + + cfg_path = project / ".specify" / "integration-catalogs.yml" + assert not cfg_path.exists(), ( + "config file should be deleted when the final catalog is removed" + ) + + # Follow-up command must succeed and show the built-in defaults, + # not error out on "contains no 'catalogs' entries". + listing = self._invoke(["integration", "catalog", "list"], project) + assert listing.exit_code == 0, listing.output + assert "default" in listing.output + assert "community" in listing.output diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index 21cb1d832e..b95caf3bee 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -5,12 +5,17 @@ class TestAgyIntegration(SkillsIntegrationTests): KEY = "agy" - FOLDER = ".agent/" + FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" - REGISTRAR_DIR = ".agent/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.""" @@ -24,4 +29,17 @@ def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path): 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 / ".agent" / "skills" / "speckit-plan" / "SKILL.md").exists() + 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_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index e274b52242..0b74a6f1a9 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -98,8 +98,25 @@ def test_templates_are_processed(self, tmp_path): 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" - assert "\nagent_scripts:\n" not in content, f"{f.name} has unstripped agent_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) @@ -132,30 +149,35 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- - - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # -- Context section --------------------------------------------------- - def test_scripts_tracked_in_manifest(self, tmp_path): + 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) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + 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) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + 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 ------------------------------------------------- @@ -203,6 +225,30 @@ def test_integration_flag_creates_files(self, tmp_path): 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 = [ @@ -220,10 +266,6 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.md") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files files.append(f".specify/integration.json") files.append(f".specify/init-options.json") @@ -232,19 +274,27 @@ def _expected_files(self, script_variant: str) -> list[str]: if script_variant == "sh": for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", - "setup-plan.sh", "update-agent-context.sh"]: + "setup-plan.sh", "setup-tasks.sh"]: files.append(f".specify/scripts/bash/{name}") else: for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", - "setup-plan.ps1", "update-agent-context.ps1"]: + "setup-plan.ps1", "setup-tasks.ps1"]: files.append(f".specify/scripts/powershell/{name}") - for name in ["agent-file-template.md", "checklist-template.md", + 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): diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 007386611c..89140de1c3 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -159,6 +159,22 @@ def test_templates_are_processed(self, tmp_path): 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.""" @@ -173,6 +189,23 @@ def test_skill_body_has_content(self, tmp_path): 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) @@ -217,30 +250,34 @@ def test_pre_existing_skills_not_removed(self, tmp_path): assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" - # -- Scripts ---------------------------------------------------------- + # -- Context section --------------------------------------------------- - def test_setup_installs_update_context_scripts(self, tmp_path): + 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) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - def test_scripts_tracked_in_manifest(self, tmp_path): + 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) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - i.setup(tmp_path, m) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + 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 ------------------------------------------------- @@ -286,6 +323,30 @@ def test_integration_flag_creates_files(self, tmp_path): 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): @@ -316,8 +377,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/init-options.json", ".specify/integration.json", f".specify/integrations/{self.KEY}.manifest.json", - f".specify/integrations/{self.KEY}/scripts/update-context.ps1", - f".specify/integrations/{self.KEY}/scripts/update-context.sh", ".specify/integrations/speckit.manifest.json", ".specify/memory/constitution.md", ] @@ -328,7 +387,7 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", - ".specify/scripts/bash/update-agent-context.sh", + ".specify/scripts/bash/setup-tasks.sh", ] else: files += [ @@ -336,17 +395,24 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", - ".specify/scripts/powershell/update-agent-context.ps1", + ".specify/scripts/powershell/setup-tasks.ps1", ] # Templates files += [ - ".specify/templates/agent-file-template.md", ".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): diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index fcded1834e..56862e534c 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -84,7 +84,9 @@ def test_setup_writes_to_correct_directory(self, tmp_path): 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" + 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: @@ -104,6 +106,7 @@ def test_templates_are_processed(self, tmp_path): 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.""" @@ -134,6 +137,12 @@ def test_toml_uses_correct_arg_placeholder(self, tmp_path): # 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"), @@ -156,19 +165,13 @@ def test_toml_uses_correct_arg_placeholder(self, tmp_path): ), ], ) - def test_toml_extract_description_supports_block_scalars(self, frontmatter, expected): + 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 = ( - "---\n" - "description: |\n" - " line one\n" - " ---\n" - " line two\n" - "---\n" - "Body\n" - ) + content = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n" frontmatter, body = TomlIntegration._split_frontmatter(content) @@ -205,7 +208,7 @@ def test_toml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "---" not in parsed["prompt"] def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch): - """Multiline body ending with `"` must not produce `""""` (#2113).""" + """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( @@ -230,7 +233,9 @@ def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch): 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" + 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.""" @@ -254,11 +259,15 @@ def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch) 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" + 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" + 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).""" @@ -284,8 +293,9 @@ def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch): 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"""'), \ + 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.""" @@ -301,6 +311,23 @@ def test_toml_is_valid(self, tmp_path): 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) @@ -332,30 +359,34 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- + # -- Context section --------------------------------------------------- - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - def test_scripts_tracked_in_manifest(self, tmp_path): + 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) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + 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) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + 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 ------------------------------------------------- @@ -369,10 +400,20 @@ def test_ai_flag_auto_promotes(self, tmp_path): 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) + 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}" @@ -390,24 +431,67 @@ def test_integration_flag_creates_files(self, tmp_path): 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) + 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}" + 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", + "analyze", + "checklist", + "clarify", + "constitution", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", ] def _expected_files(self, script_variant: str) -> list[str]: @@ -420,31 +504,49 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.toml") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files - files.append(f".specify/integration.json") - files.append(f".specify/init-options.json") + files.append(".specify/integration.json") + files.append(".specify/init-options.json") files.append(f".specify/integrations/{self.KEY}.manifest.json") - files.append(f".specify/integrations/speckit.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", "update-agent-context.sh"]: + for name in [ + "check-prerequisites.sh", + "common.sh", + "create-new-feature.sh", + "setup-plan.sh", + "setup-tasks.sh", + ]: files.append(f".specify/scripts/bash/{name}") else: - for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", - "setup-plan.ps1", "update-agent-context.ps1"]: + for name in [ + "check-prerequisites.ps1", + "common.ps1", + "create-new-feature.ps1", + "setup-plan.ps1", + "setup-tasks.ps1", + ]: files.append(f".specify/scripts/powershell/{name}") - for name in ["agent-file-template.md", "checklist-template.md", - "constitution-template.md", "plan-template.md", - "spec-template.md", "tasks-template.md"]: + 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): @@ -457,15 +559,26 @@ def test_complete_file_inventory_sh(self, tmp_path): 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) + 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()) + 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" @@ -482,15 +595,26 @@ def test_complete_file_inventory_ps(self, tmp_path): 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) + 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()) + 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" diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py new file mode 100644 index 0000000000..956c7a796f --- /dev/null +++ b/tests/integrations/test_integration_base_yaml.py @@ -0,0 +1,501 @@ +"""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", + "setup-tasks.sh", + ]: + files.append(f".specify/scripts/bash/{name}") + else: + for name in [ + "check-prerequisites.ps1", + "common.ps1", + "create-new-feature.ps1", + "setup-plan.ps1", + "setup-tasks.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_catalog.py b/tests/integrations/test_integration_catalog.py new file mode 100644 index 0000000000..2e285e17e1 --- /dev/null +++ b/tests/integrations/test_integration_catalog.py @@ -0,0 +1,1531 @@ +"""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, + IntegrationValidationError, +) + + +# --------------------------------------------------------------------------- +# 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") as exc_info: + cat.get_active_catalogs() + assert str(cfg) in str(exc_info.value) + + def test_empty_config_file_raises_no_catalogs(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + specify = tmp_path / ".specify" + specify.mkdir() + cfg = specify / "integration-catalogs.yml" + cfg.write_text("", encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="no 'catalogs' entries" + ) as exc_info: + cat.get_active_catalogs() + assert str(cfg) in str(exc_info.value) + + @pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"]) + def test_load_catalog_config_rejects_falsy_non_mapping_roots( + self, tmp_path, monkeypatch, config_content + ): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + specify = tmp_path / ".specify" + specify.mkdir() + cfg = specify / "integration-catalogs.yml" + cfg.write_text(config_content, encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, + match="expected a YAML mapping at the root", + ) as exc_info: + cat.get_active_catalogs() + assert str(cfg) in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# 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 authentication.http.urllib.request.urlopen to return *catalog_data*.""" + + class FakeResponse: + def __init__(self, data, url=""): + self._data = json.dumps(data).encode() + self._url = url if isinstance(url, str) else url.full_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(req, timeout=10): + url = req if isinstance(req, str) else req.full_url + return FakeResponse(catalog_data, url) + + import specify_cli.authentication.http as _auth_http + monkeypatch.setattr(_auth_http.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 specify_cli.authentication.http as _auth_http + + class FakeResponse: + def __init__(self, data, url=""): + self._data = json.dumps(data).encode() + self._url = url if isinstance(url, str) else url.full_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(_auth_http.urllib.request, "urlopen", + lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_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 installed" 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 + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — catalog source management (get_catalog_configs / add / remove) +# --------------------------------------------------------------------------- + + +class TestCatalogSourceManagement: + """Unit tests for add_catalog / remove_catalog / get_catalog_configs.""" + + def _isolate(self, tmp_path, monkeypatch): + """Point HOME at tmp_path and clear the env override so we read built-ins.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + + def test_get_catalog_configs_returns_builtin_stack(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + configs = cat.get_catalog_configs() + assert [c["name"] for c in configs] == ["default", "community"] + assert all(isinstance(c["url"], str) and c["url"] for c in configs) + assert configs[0]["install_allowed"] is True + assert configs[1]["install_allowed"] is False + + def test_add_catalog_creates_config_file(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://new.example.com/catalog.json", name="mine") + + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + assert cfg_path.exists() + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"] == [ + { + "name": "mine", + "url": "https://new.example.com/catalog.json", + "priority": 1, + "install_allowed": True, + "description": "", + } + ] + # Round-trip: active catalogs should now come from the config file. + active = cat.get_active_catalogs() + assert [e.name for e in active] == ["mine"] + + def test_add_catalog_recovers_from_empty_config_file(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text("", encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://example.com/catalog.json") + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"] == [ + { + "name": "catalog-1", + "url": "https://example.com/catalog.json", + "priority": 1, + "install_allowed": True, + "description": "", + } + ] + + @pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"]) + def test_add_catalog_rejects_falsy_non_mapping_config_roots( + self, tmp_path, monkeypatch, config_content + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text(config_content, encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, + match="corrupted.*expected a mapping", + ) as exc_info: + cat.add_catalog("https://example.com/catalog.json") + assert str(cfg_path) in str(exc_info.value) + + def test_add_catalog_auto_derives_name_and_priority(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://a.example.com/catalog.json") + cat.add_catalog("https://b.example.com/catalog.json") + + data = yaml.safe_load( + (tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8") + ) + entries = data["catalogs"] + assert [e["name"] for e in entries] == ["catalog-1", "catalog-2"] + assert [e["priority"] for e in entries] == [1, 2] + + def test_add_catalog_normalizes_name(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://a.example.com/catalog.json", name=" mine ") + cat.add_catalog("https://b.example.com/catalog.json", name=" ") + + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + entries = data["catalogs"] + assert [e["name"] for e in entries] == ["mine", "catalog-2"] + + def test_add_catalog_rejects_duplicate_url(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://dup.example.com/catalog.json") + with pytest.raises(IntegrationValidationError, match="already configured"): + cat.add_catalog("https://dup.example.com/catalog.json") + + def test_add_catalog_rejects_invalid_url(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationCatalogError, match="HTTPS"): + cat.add_catalog("http://insecure.example.com/catalog.json") + assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists() + + def test_add_catalog_rejects_empty_url(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationValidationError, match="must be non-empty"): + cat.add_catalog(" ") + assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists() + + def test_remove_catalog_without_config_errors(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationValidationError, match="No catalog config"): + cat.remove_catalog(0) + + def test_remove_catalog_happy_path(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://a.example.com/catalog.json", name="a") + cat.add_catalog("https://b.example.com/catalog.json", name="b") + + removed = cat.remove_catalog(0) + assert removed == "a" + + data = yaml.safe_load( + (tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8") + ) + assert [e["name"] for e in data["catalogs"]] == ["b"] + + def test_remove_catalog_index_out_of_range(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://a.example.com/catalog.json", name="a") + with pytest.raises(IntegrationValidationError, match="out of range"): + cat.remove_catalog(5) + with pytest.raises(IntegrationValidationError, match="out of range"): + cat.remove_catalog(-1) + + def test_corrupt_config_rejected_on_add(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text("- just\n- a\n- list\n", encoding="utf-8") + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationValidationError, match="corrupted") as exc_info: + cat.add_catalog("https://new.example.com/catalog.json") + assert str(cfg_path) in str(exc_info.value) + + def test_add_catalog_rejects_non_list_catalogs_with_config_path( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8" + ) + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="invalid 'catalogs' value" + ) as exc_info: + cat.add_catalog("https://new.example.com/catalog.json") + assert str(cfg_path) in str(exc_info.value) + + def test_add_catalog_rejects_non_mapping_entry_with_config_path( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump({"catalogs": ["not-a-mapping"]}), encoding="utf-8" + ) + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="Invalid catalog entry at index 0" + ) as exc_info: + cat.add_catalog("https://new.example.com/catalog.json") + message = str(exc_info.value) + assert str(cfg_path) in message + assert "expected a mapping" in message + + def test_add_catalog_skips_blank_url_entries(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": " ", "name": "blank", "priority": 99}, + { + "url": "https://a.example.com/catalog.json", + "name": "a", + "priority": 5, + }, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://b.example.com/catalog.json", name="b") + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"][-1]["name"] == "b" + assert data["catalogs"][-1]["priority"] == 6 + + def test_add_catalog_default_name_ignores_blank_url_entries( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump({"catalogs": [{"url": " ", "name": "blank"}]}), + encoding="utf-8", + ) + + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://example.com/catalog.json") + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"][-1]["name"] == "catalog-1" + + def test_add_catalog_rejects_non_integer_priority(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://a.example.com/catalog.json", + "name": "a", + "priority": "first", + } + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, + match="'priority' must be an integer, got 'first'", + ): + cat.add_catalog("https://b.example.com/catalog.json") + + def test_add_catalog_accepts_numeric_string_priority(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://a.example.com/catalog.json", + "name": "a", + "priority": "10", + } + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://b.example.com/catalog.json", name="b") + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"][-1]["name"] == "b" + assert data["catalogs"][-1]["priority"] == 11 + + @pytest.mark.parametrize( + ("bad_url", "reason"), + [ + ("http://insecure.example.com/catalog.json", "HTTPS"), + (123, "HTTPS"), + ], + ) + def test_add_catalog_rejects_existing_entry_with_bad_url( + self, tmp_path, monkeypatch, bad_url, reason + ): + """A sibling entry with an http:// URL should block a new add.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": bad_url, + "name": "bad", + } + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationValidationError) as exc_info: + cat.add_catalog("https://good.example.com/catalog.json") + message = str(exc_info.value) + assert str(cfg_path) in message + assert "index 0" in message + assert reason in message + + def test_add_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch): + """Invalid YAML on disk surfaces as IntegrationValidationError, not a raw YAMLError.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n" + cfg_path.write_text(invalid_yaml, encoding="utf-8") + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="Failed to read catalog config" + ): + cat.add_catalog("https://b.example.com/catalog.json") + + def test_remove_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch): + """Invalid YAML on disk surfaces as IntegrationValidationError from remove_catalog too.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n" + cfg_path.write_text(invalid_yaml, encoding="utf-8") + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="Failed to read catalog config" + ): + cat.remove_catalog(0) + + def test_add_catalog_defaults_missing_priority_to_index_plus_one( + self, tmp_path, monkeypatch + ): + """Existing entries without `priority` should be treated as idx + 1. + + Matches the rule in `_load_catalog_config()`: a valid catalog entry + without an explicit `priority` sorts at `idx + 1`, so the new entry + should get `max(...) + 1` from those derived values. + """ + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + # No explicit priority → should be treated as 1 + {"url": "https://a.example.com/cat.json", "name": "a"}, + # No explicit priority → should be treated as 2 + {"url": "https://b.example.com/cat.json", "name": "b"}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://c.example.com/cat.json", name="c") + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + new_entry = data["catalogs"][-1] + assert new_entry["name"] == "c" + # max(implicit [1, 2]) + 1 == 3 + assert new_entry["priority"] == 3 + + def test_add_catalog_strips_whitespace_in_url(self, tmp_path, monkeypatch): + """Whitespace around the incoming URL should be normalized before write.""" + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog(" https://a.example.com/catalog.json\n", name="a") + + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"][0]["url"] == "https://a.example.com/catalog.json" + + def test_add_catalog_rejects_whitespace_only_duplicate(self, tmp_path, monkeypatch): + """A second add with only whitespace differences must be rejected as a duplicate.""" + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://a.example.com/catalog.json", name="a") + with pytest.raises(IntegrationValidationError, match="already configured"): + cat.add_catalog(" https://a.example.com/catalog.json ") + + def test_remove_catalog_wraps_unlink_oserror(self, tmp_path, monkeypatch): + """An OSError from `Path.unlink` surfaces as IntegrationValidationError.""" + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://only.example.com/catalog.json", name="only") + + from pathlib import Path as _Path + + def boom(self, *args, **kwargs): + raise OSError("simulated unlink failure") + + monkeypatch.setattr(_Path, "unlink", boom) + + with pytest.raises( + IntegrationValidationError, match="Failed to delete catalog config" + ): + cat.remove_catalog(0) + + def test_remove_catalog_ignores_missing_final_config_during_unlink( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://only.example.com/catalog.json", name="only") + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + + from pathlib import Path as _Path + + original_unlink = _Path.unlink + + def delete_first_then_unlink(self, *args, **kwargs): + if self == cfg_path and self.exists(): + original_unlink(self) + return original_unlink(self, *args, **kwargs) + + monkeypatch.setattr(_Path, "unlink", delete_first_then_unlink) + + assert cat.remove_catalog(0) == "only" + assert not cfg_path.exists() + + def test_remove_catalog_empty_list_gives_clear_error(self, tmp_path, monkeypatch): + """Hand-edited empty `catalogs:` produces a clear error, not '0--1'.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text(yaml.dump({"catalogs": []}), encoding="utf-8") + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="contains no catalog entries" + ): + cat.remove_catalog(0) + + def test_remove_catalog_empty_config_file_gives_clear_error( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text("", encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="contains no catalog entries" + ): + cat.remove_catalog(0) + + def test_remove_catalog_rejects_non_list_catalogs_with_config_path( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8" + ) + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="invalid 'catalogs' value" + ) as exc_info: + cat.remove_catalog(0) + assert str(cfg_path) in str(exc_info.value) + + @pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"]) + def test_remove_catalog_rejects_falsy_non_mapping_config_roots( + self, tmp_path, monkeypatch, config_content + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text(config_content, encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, + match="corrupted.*expected a mapping", + ) as exc_info: + cat.remove_catalog(0) + assert str(cfg_path) in str(exc_info.value) + + def test_remove_last_catalog_deletes_file_and_restores_defaults( + self, tmp_path, monkeypatch + ): + """Removing the final catalog must not leave behind `catalogs: []`. + + `_load_catalog_config` treats an empty `catalogs` list as an error, + so writing that file would break every subsequent `integration` + command. Removing the last entry should delete the config file so the + project falls back to built-in defaults. + """ + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + + cat.add_catalog("https://only.example.com/catalog.json", name="only") + assert cfg_path.exists() + assert [e.name for e in cat.get_active_catalogs()] == ["only"] + + removed = cat.remove_catalog(0) + assert removed == "only" + + assert not cfg_path.exists(), ( + "remove_catalog should delete the config file when emptying it" + ) + # Follow-up loads fall back to built-in defaults, not an error. + active = cat.get_active_catalogs() + assert [e.name for e in active] == ["default", "community"] + + def test_load_catalog_config_raises_validation_error_for_invalid_yaml( + self, tmp_path, monkeypatch + ): + """Local-config problems must surface as IntegrationValidationError so + CLI handlers can route them to local-config (not network) guidance.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + invalid_yaml = "catalogs:\n - [bad\n" + cfg_path.write_text(invalid_yaml, encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + # Subclass match: IntegrationValidationError (specifically), not the + # bare IntegrationCatalogError parent that callers used previously. + with pytest.raises(IntegrationValidationError, match="Failed to read catalog config"): + cat.get_active_catalogs() + + def test_load_catalog_config_rejects_boolean_priority(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://a.example.com/catalog.json", + "name": "a", + "priority": True, + } + ] + } + ), + encoding="utf-8", + ) + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="Invalid priority|expected integer" + ) as exc_info: + cat.get_active_catalogs() + assert str(cfg_path) in str(exc_info.value) + + @pytest.mark.parametrize("raw_name", [None, " "]) + def test_load_catalog_config_defaults_blank_names( + self, tmp_path, monkeypatch, raw_name + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": " ", + "name": "skipped", + }, + { + "url": "https://example.com/catalog.json", + "name": raw_name, + } + ] + } + ), + encoding="utf-8", + ) + + cat = IntegrationCatalog(tmp_path) + + assert [entry.name for entry in cat.get_active_catalogs()] == ["catalog-1"] + + @pytest.mark.parametrize( + ("raw_name", "expected"), + [ + (None, "https://one.example.com/c.json"), + (" ", "https://one.example.com/c.json"), + (123, "123"), + ], + ) + def test_remove_catalog_normalizes_removed_display_name( + self, tmp_path, monkeypatch, raw_name, expected + ): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://one.example.com/c.json", name="one") + + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + data["catalogs"][0]["name"] = raw_name + cfg_path.write_text(yaml.dump(data), encoding="utf-8") + + assert cat.remove_catalog(0) == expected + + def test_remove_catalog_uses_display_order_with_explicit_priorities( + self, tmp_path, monkeypatch + ): + """`remove_catalog(index)` must remove the entry shown at that index by + `catalog list`, not the entry at that raw YAML position.""" + self._isolate(tmp_path, monkeypatch) + # YAML order: alpha (priority=20), beta (priority=10), gamma (priority=15). + # Display (sorted by priority asc): beta (10), gamma (15), alpha (20). + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": "https://alpha.example.com/c.json", "name": "alpha", "priority": 20}, + {"url": "https://beta.example.com/c.json", "name": "beta", "priority": 10}, + {"url": "https://gamma.example.com/c.json", "name": "gamma", "priority": 15}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + # Display index 0 = beta (lowest priority), not alpha (raw YAML idx 0). + removed = cat.remove_catalog(0) + assert removed == "beta" + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + remaining_names = [c["name"] for c in data["catalogs"]] + # YAML order is preserved for the survivors; only beta is gone. + assert remaining_names == ["alpha", "gamma"] + + def test_remove_catalog_display_order_with_missing_priorities( + self, tmp_path, monkeypatch + ): + """Entries without `priority` default to `idx + 1` (matching + `_load_catalog_config`), so display order tracks YAML order and the + first display entry is the first YAML entry.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": "https://one.example.com/c.json", "name": "one"}, + {"url": "https://two.example.com/c.json", "name": "two"}, + {"url": "https://three.example.com/c.json", "name": "three"}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + # Implicit priorities: one=1, two=2, three=3 → display order matches YAML. + removed = cat.remove_catalog(0) + assert removed == "one" + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert [c["name"] for c in data["catalogs"]] == ["two", "three"] + + def test_remove_catalog_bool_priority_falls_back_to_yaml_index( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": "https://one.example.com/c.json", "name": "one"}, + { + "url": "https://bool.example.com/c.json", + "name": "bool", + "priority": False, + }, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + removed = cat.remove_catalog(0) + + assert removed == "one" + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert [c["name"] for c in data["catalogs"]] == ["bool"] + + def test_remove_catalog_display_order_skips_blank_url_entries( + self, tmp_path, monkeypatch + ): + """Blank-url entries are not shown by catalog list, so remove skips them too.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": " ", "name": "blank", "priority": 0}, + {"url": "https://one.example.com/c.json", "name": "one"}, + {"url": "https://two.example.com/c.json", "name": "two"}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + removed = cat.remove_catalog(0) + assert removed == "one" + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert [c["name"] for c in data["catalogs"]] == ["blank", "two"] + + def test_remove_catalog_deletes_file_when_only_skipped_entries_remain( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": " ", "name": "blank", "priority": 0}, + {"url": "https://one.example.com/c.json", "name": "one"}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + removed = cat.remove_catalog(0) + assert removed == "one" + assert not cfg_path.exists() + + active = cat.get_active_catalogs() + assert [e.name for e in active] == ["default", "community"] + + def test_remove_catalog_allows_numeric_url_entry_cleanup( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump({"catalogs": [{"name": "numeric-url", "url": 123}]}), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + removed = cat.remove_catalog(0) + + assert removed == "numeric-url" + assert not cfg_path.exists() + + def test_remove_catalog_errors_when_no_entries_are_removable( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": "", "name": "empty"}, + {"name": "missing"}, + "not-a-mapping", + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + with pytest.raises( + IntegrationValidationError, + match="no removable catalog entries", + ): + cat.remove_catalog(0) + + def test_remove_catalog_display_order_mixes_explicit_and_default( + self, tmp_path, monkeypatch + ): + """An explicit low priority should sort ahead of default-priority + siblings, even if it appears later in the YAML.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + # Defaults: a=1, b=2 (implicit). Explicit c=0 → display: c, a, b. + # The blank name should fall back to the removed URL, not raw YAML idx. + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": "https://a.example.com/c.json", "name": "a"}, + {"url": "https://b.example.com/c.json", "name": "b"}, + { + "url": "https://c.example.com/c.json", + "name": " ", + "priority": 0, + }, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + removed = cat.remove_catalog(0) + assert removed == "https://c.example.com/c.json" + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert [c["name"] for c in data["catalogs"]] == ["a", "b"] diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 7fd69df176..142db0dd92 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,5 +1,6 @@ """Tests for ClaudeIntegration.""" +import codecs import json import os from unittest.mock import patch @@ -54,27 +55,67 @@ def test_setup_creates_skill_files(self, tmp_path): 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 True + assert parsed["disable-model-invocation"] is False assert parsed["metadata"]["source"] == "templates/commands/plan.md" - def test_setup_installs_update_context_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) - created = integration.setup(tmp_path, manifest, script_type="sh") + 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 - scripts_dir = tmp_path / ".specify" / "integrations" / "claude" / "scripts" - assert scripts_dir.is_dir() - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # 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") - tracked = {path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created} - assert ".specify/integrations/claude/scripts/update-context.sh" in tracked - assert ".specify/integrations/claude/scripts/update-context.ps1" in tracked + 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", + ".specify/scripts/bash/setup-tasks.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 index 71b7db1c98..352a0475b5 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -1,11 +1,108 @@ """Tests for CursorAgentIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +from pathlib import Path +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest -class TestCursorAgentIntegration(MarkdownIntegrationTests): +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestCursorAgentIntegration(SkillsIntegrationTests): KEY = "cursor-agent" FOLDER = ".cursor/" - COMMANDS_SUBDIR = "commands" - REGISTRAR_DIR = ".cursor/commands" + 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_devin.py b/tests/integrations/test_integration_devin.py new file mode 100644 index 0000000000..d218513d63 --- /dev/null +++ b/tests/integrations/test_integration_devin.py @@ -0,0 +1,75 @@ +"""Tests for DevinIntegration.""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestDevinIntegration(SkillsIntegrationTests): + KEY = "devin" + FOLDER = ".devin/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".devin/skills" + CONTEXT_FILE = "AGENTS.md" + + +class TestDevinBuildExecArgs: + """Regression tests for DevinIntegration.build_exec_args. + + Devin's CLI has no --output-format flag, so build_exec_args must + omit it regardless of the output_json argument. The integration + must also remain dispatchable (must not return None, which is the + codebase's IDE-only sentinel checked by CommandStep). + """ + + def test_returns_args_not_none_for_dispatch(self): + """Devin is CLI-dispatchable; build_exec_args must not return None.""" + from specify_cli.integrations.devin import DevinIntegration + + impl = DevinIntegration() + args = impl.build_exec_args("test prompt") + assert args is not None, ( + "DevinIntegration.build_exec_args must not return None. " + "None is the codebase sentinel for IDE-only integrations " + "(see WindsurfIntegration); Devin is dispatchable via 'devin -p'." + ) + assert args[:3] == ["devin", "-p", "test prompt"] + + def test_output_json_does_not_emit_output_format_flag(self): + """Devin has no --output-format flag; output_json=True must not add it.""" + from specify_cli.integrations.devin import DevinIntegration + + impl = DevinIntegration() + args_json = impl.build_exec_args("hello", output_json=True) + args_text = impl.build_exec_args("hello", output_json=False) + + assert "--output-format" not in args_json + assert "json" not in args_json[3:] + # The two should be identical: output_json is documented as having + # no effect on the command line for Devin (plain-text stdout). + assert args_json == args_text + + def test_model_flag_passed_through(self): + """--model is supported and should appear when provided.""" + from specify_cli.integrations.devin import DevinIntegration + + impl = DevinIntegration() + args = impl.build_exec_args("hi", model="claude-sonnet-4") + assert args == ["devin", "-p", "hi", "--model", "claude-sonnet-4"] + + +class TestDevinAutoPromote: + """--ai devin auto-promotes to integration path.""" + + def test_ai_devin_without_ai_skills_auto_promotes(self, tmp_path): + """--ai devin should work the same as --integration devin.""" + 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", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"], + ) + + assert result.exit_code == 0, f"init --ai devin failed: {result.output}" + assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists() \ No newline at end of file diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 10905723fb..62fee73210 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -2,6 +2,47 @@ 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: @@ -32,19 +73,16 @@ def test_setup_creates_md_files(self, tmp_path): for f in command_files: assert f.name.endswith(".md") - def test_setup_installs_update_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) - created = forge.setup(tmp_path, m) - script_files = [f for f in created if "scripts" in f.parts] - assert len(script_files) > 0 - sh_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.sh" - ps_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.ps1" - assert sh_script in created - assert ps_script in created - assert sh_script.exists() - assert ps_script.exists() + 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 @@ -103,6 +141,7 @@ def test_directory_structure(self, tmp_path): assert actual_commands == expected_commands def test_templates_are_processed(self, tmp_path): + import re from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) @@ -114,28 +153,50 @@ def test_templates_are_processed(self, tmp_path): 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 - assert "\nagent_scripts:\n" not in content + # Check Forge-specific: command references use hyphen notation, not dot notation + assert not re.search(r"/speckit\.[a-z]", content), ( + f"{cmd_file.name} contains dot-notation command reference (/speckit.); " + "Forge requires hyphen notation (/speckit-) for ZSH compatibility" + ) + + 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 "\nname: " in content, f"{cmd_file.name} missing injected 'name' field" + assert "name" in frontmatter, f"{cmd_file.name} missing injected 'name' field in frontmatter" # Check that handoffs frontmatter key is stripped - assert "\nhandoffs:" not in content, f"{cmd_file.name} has unstripped 'handoffs' key" + 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.""" @@ -168,3 +229,253 @@ def test_uses_parameters_placeholder(self, tmp_path): assert "{{parameters}}" in content, ( "checklist should contain {{parameters}} in User Input section" ) + + def test_command_refs_use_hyphen_notation(self, tmp_path): + """Verify all generated Forge command files use /speckit-foo, not /speckit.foo.""" + import re + 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" + + files_with_refs = [] + files_with_dot_refs = [] + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + if re.search(r"/speckit-[a-z]", content): + files_with_refs.append(cmd_file.name) + if re.search(r"/speckit\.[a-z]", content): + files_with_dot_refs.append(cmd_file.name) + + assert files_with_dot_refs == [], ( + f"Files contain dot-notation command references: {files_with_dot_refs}. " + "Forge requires hyphen notation (/speckit-) for ZSH compatibility." + ) + assert len(files_with_refs) > 0, ( + "Expected at least one generated Forge command to contain /speckit- reference, " + "but none were found. Check that __SPECKIT_COMMAND_*__ tokens are being resolved." + ) + + 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" + ) + + def test_git_extension_command_uses_hyphen_notation(self, tmp_path): + """Verify the git extension's feature command uses /speckit-specify (not /speckit.specify) for Forge.""" + from pathlib import Path + from specify_cli.agents import CommandRegistrar + + # Locate the real git extension command source file + repo_root = Path(__file__).resolve().parent.parent.parent + ext_dir = repo_root / "extensions" / "git" + cmd_source = ext_dir / "commands" / "speckit.git.feature.md" + assert cmd_source.exists(), ( + f"Git extension command source not found at {cmd_source}. " + "Ensure extensions/git/commands/speckit.git.feature.md exists." + ) + + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.git.feature", + "file": "commands/speckit.git.feature.md", + } + ] + + registered = registrar.register_commands( + "forge", + commands, + "git", + ext_dir, + tmp_path, + ) + + assert "speckit.git.feature" in registered + + forge_cmd = tmp_path / ".forge" / "commands" / "speckit.git.feature.md" + assert forge_cmd.exists(), "Expected Forge command file was not created" + + content = forge_cmd.read_text(encoding="utf-8") + assert "/speckit-specify" in content, ( + "Expected '/speckit-specify' (hyphen) in generated Forge git.feature command body, " + "but it was not found. Check that __SPECKIT_COMMAND_SPECIFY__ is resolved correctly." + ) + assert "/speckit.specify" not in content, ( + "Found '/speckit.specify' (dot notation) in generated Forge git.feature command body. " + "Forge requires hyphen notation for ZSH compatibility." + ) diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 2815456f21..4f515a01d2 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -31,9 +31,9 @@ def test_config_requires_cli_false(self): i = get_integration("generic") assert i.config["requires_cli"] is False - def test_context_file_is_none(self): + def test_context_file_is_agents_md(self): i = get_integration("generic") - assert i.context_file is None + assert i.context_file == "AGENTS.md" # -- Options ---------------------------------------------------------- @@ -101,6 +101,7 @@ def test_templates_are_processed(self, tmp_path): 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") @@ -158,30 +159,41 @@ def test_different_commands_dirs(self, tmp_path): cmd_files = [f for f in created if "scripts" not in f.parts] assert len(cmd_files) > 0 - # -- Scripts ---------------------------------------------------------- + # -- Context section --------------------------------------------------- - def test_setup_installs_update_context_scripts(self, tmp_path): + 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"}) - scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts" - assert scripts_dir.is_dir(), "Scripts directory not created for generic" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - def test_scripts_tracked_in_manifest(self, tmp_path): + 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"}) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 + 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 - def test_sh_script_is_executable(self, tmp_path): + def test_implement_loads_constitution_context(self, tmp_path): + """The generated implement command should load constitution governance context.""" i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + implement_file = tmp_path / ".custom" / "cmds" / "speckit.implement.md" + assert implement_file.exists() + content = implement_file.read_text(encoding="utf-8") + assert ".specify/memory/constitution.md" in content # -- CLI -------------------------------------------------------------- @@ -198,6 +210,28 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): # 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 @@ -221,6 +255,7 @@ def test_complete_file_inventory_sh(self, tmp_path): 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", @@ -233,21 +268,20 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", - ".specify/integrations/generic/scripts/update-context.ps1", - ".specify/integrations/generic/scripts/update-context.sh", ".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/scripts/bash/update-agent-context.sh", - ".specify/templates/agent-file-template.md", + ".specify/scripts/bash/setup-tasks.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" @@ -277,6 +311,7 @@ def test_complete_file_inventory_ps(self, tmp_path): 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", @@ -289,21 +324,20 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", - ".specify/integrations/generic/scripts/update-context.ps1", - ".specify/integrations/generic/scripts/update-context.sh", ".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/scripts/powershell/update-agent-context.ps1", - ".specify/templates/agent-file-template.md", + ".specify/scripts/powershell/setup-tasks.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" diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py new file mode 100644 index 0000000000..8415081d53 --- /dev/null +++ b/tests/integrations/test_integration_goose.py @@ -0,0 +1,39 @@ +"""Tests for GooseIntegration.""" + +import yaml +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + +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" + + def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path): + # “If a generated Goose recipe uses {{args}} in its prompt, it + # must declare a corresponding args parameter.” + + integration = get_integration("goose") + assert integration is not None + + manifest = IntegrationManifest("goose", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + recipe_files = [path for path in created if path.suffix == ".yaml"] + assert recipe_files + + for recipe_file in recipe_files: + data = yaml.safe_load(recipe_file.read_text(encoding="utf-8")) + + if "{{args}}" not in data["prompt"]: + continue + + assert any( + param.get("key") == "args" + for param in data.get("parameters", []) + ), f"{recipe_file} uses {{{{args}}}} but does not declare args" diff --git a/tests/integrations/test_integration_lingma.py b/tests/integrations/test_integration_lingma.py new file mode 100644 index 0000000000..959de8d657 --- /dev/null +++ b/tests/integrations/test_integration_lingma.py @@ -0,0 +1,11 @@ +"""Tests for LingmaIntegration.""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestLingmaIntegration(SkillsIntegrationTests): + KEY = "lingma" + FOLDER = ".lingma/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".lingma/skills" + CONTEXT_FILE = ".lingma/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index 4f3aee5d9b..427fd15167 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -1,5 +1,7 @@ """Tests for OpencodeIntegration.""" +from specify_cli.integrations import get_integration + from .test_integration_base_markdown import MarkdownIntegrationTests @@ -9,3 +11,49 @@ class TestOpencodeIntegration(MarkdownIntegrationTests): COMMANDS_SUBDIR = "command" REGISTRAR_DIR = ".opencode/command" CONTEXT_FILE = "AGENTS.md" + + def test_build_exec_args_uses_run_command_dispatch(self): + integration = get_integration(self.KEY) + + args = integration.build_exec_args( + "/speckit.specify build a login page", + output_json=False, + ) + + assert args == [ + "opencode", + "run", + "--command", + "speckit.specify", + "build a login page", + ] + assert "-p" not in args + assert "--output-format" not in args + + def test_build_exec_args_maps_model_and_json_flags(self): + integration = get_integration(self.KEY) + + args = integration.build_exec_args( + "/speckit.plan add OAuth", + model="anthropic/claude-sonnet-4", + output_json=True, + ) + + assert args == [ + "opencode", + "run", + "--command", + "speckit.plan", + "-m", + "anthropic/claude-sonnet-4", + "--format", + "json", + "add OAuth", + ] + + def test_build_exec_args_keeps_plain_prompt_dispatch(self): + integration = get_integration(self.KEY) + + args = integration.build_exec_args("explain this repository", output_json=False) + + assert args == ["opencode", "run", "explain this repository"] diff --git a/tests/integrations/test_integration_state.py b/tests/integrations/test_integration_state.py new file mode 100644 index 0000000000..1d6bdb0268 --- /dev/null +++ b/tests/integrations/test_integration_state.py @@ -0,0 +1,86 @@ +"""Tests for integration state normalization helpers.""" + +import json + +from specify_cli.integration_state import ( + INTEGRATION_JSON, + default_integration_key, + integration_setting, + normalize_integration_state, + write_integration_json, +) + + +def test_normalize_integration_state_strips_default_key_without_duplicates(): + state = normalize_integration_state( + { + "default_integration": " claude ", + "integration": " claude ", + "installed_integrations": ["claude"], + } + ) + + assert state["integration"] == "claude" + assert state["default_integration"] == "claude" + assert state["installed_integrations"] == ["claude"] + + +def test_normalize_integration_state_strips_legacy_key_fallback(): + state = normalize_integration_state( + { + "integration": " codex ", + "installed_integrations": [], + } + ) + + assert state["integration"] == "codex" + assert state["default_integration"] == "codex" + assert state["installed_integrations"] == ["codex"] + + +def test_normalize_integration_state_preserves_newer_schema(): + state = normalize_integration_state( + { + "integration_state_schema": 99, + "integration": "claude", + "installed_integrations": ["claude"], + "future_field": {"keep": True}, + } + ) + + assert state["integration_state_schema"] == 99 + assert state["future_field"] == {"keep": True} + + +def test_default_integration_key_strips_raw_state_values(): + assert default_integration_key({"default_integration": " claude "}) == "claude" + assert default_integration_key({"integration": " codex "}) == "codex" + + +def test_integration_settings_strip_invoke_separator(): + setting = integration_setting( + { + "integration_settings": { + "claude": { + "invoke_separator": " - ", + } + } + }, + "claude", + ) + + assert setting["invoke_separator"] == "-" + + +def test_write_integration_json_strips_integration_key(tmp_path): + write_integration_json( + tmp_path, + version="1.2.3", + integration_key=" claude ", + installed_integrations=["claude"], + ) + + state = json.loads((tmp_path / INTEGRATION_JSON).read_text(encoding="utf-8")) + assert state["integration"] == "claude" + assert state["default_integration"] == "claude" + assert state["installed_integrations"] == ["claude"] diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index f5322bdf5e..750bbb6efa 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -3,6 +3,7 @@ import json import os +import pytest from typer.testing import CliRunner from specify_cli import app @@ -31,6 +32,27 @@ def _init_project(tmp_path, integration="copilot"): return project +def _run_in_project(project, args): + """Run a CLI command from inside a generated project.""" + old_cwd = os.getcwd() + try: + os.chdir(project) + return runner.invoke(app, args, catch_exceptions=False) + finally: + os.chdir(old_cwd) + + +def _write_invalid_manifest(project, key): + manifest = project / ".specify" / "integrations" / f"{key}.manifest.json" + manifest.write_bytes(b"\xff\xfe\x00") + return manifest + + +def _integration_list_row_cells(output: str, key: str) -> list[str]: + row = next(line for line in output.splitlines() if line.startswith(f"│ {key}")) + return [cell.strip() for cell in row.split("│")[1:-1]] + + # ── list ───────────────────────────────────────────────────────────── @@ -70,6 +92,39 @@ def test_list_shows_available_integrations(self, tmp_path): assert "claude" in result.output assert "gemini" in result.output + def test_list_shows_multi_install_safe_status(self, tmp_path): + project = _init_project(tmp_path, "claude") + 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 "Multi-install" in result.output + assert "Safe" in result.output + assert _integration_list_row_cells(result.output, "claude")[-1] == "yes" + assert _integration_list_row_cells(result.output, "copilot")[-1] == "no" + + def test_list_rejects_newer_integration_state_schema(self, tmp_path): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + data = json.loads(int_json.read_text(encoding="utf-8")) + data["integration_state_schema"] = 99 + int_json.write_text(json.dumps(data), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + + assert result.exit_code != 0 + normalized = " ".join(result.output.split()) + assert "schema 99" in normalized + assert "only supports schema 1" in normalized + # ── install ────────────────────────────────────────────────────────── @@ -106,7 +161,9 @@ def test_install_already_installed(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 assert "already installed" in result.output - assert "uninstall" in result.output + normalized = " ".join(result.output.split()) + assert "specify integration upgrade copilot" in normalized + assert "specify integration uninstall copilot" in normalized def test_install_different_when_one_exists(self, tmp_path): project = _init_project(tmp_path, "copilot") @@ -117,8 +174,112 @@ def test_install_different_when_one_exists(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code != 0 - assert "already installed" in result.output - assert "uninstall" in result.output + assert "Installed integrations: copilot" in result.output + assert "Default integration: copilot" in result.output + assert "--force" in result.output + + def test_install_multi_safe_integration(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "installed successfully" in result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert data["default_integration"] == "claude" + assert data["integration_state_schema"] == 1 + assert data["installed_integrations"] == ["claude", "codex"] + assert data["integration_settings"]["claude"]["invoke_separator"] == "-" + assert data["integration_settings"]["codex"]["invoke_separator"] == "-" + + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + + def test_install_additional_preserves_shared_manifest(self, tmp_path): + project = _init_project(tmp_path, "claude") + shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" + before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"]) + assert before + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"]) + assert before <= after + + def test_install_multi_safe_migrates_legacy_state(self, tmp_path): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + int_json.write_text(json.dumps({ + "integration": "claude", + "version": "0.0.0", + }), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads(int_json.read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert data["default_integration"] == "claude" + assert data["installed_integrations"] == ["claude", "codex"] + + def test_install_multi_unsafe_requires_force(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Installed integrations: copilot" in result.output + assert "multi-install safe" in result.output + assert "--force" in result.output + + def test_install_multi_unsafe_allowed_with_force(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + assert data["installed_integrations"] == ["copilot", "claude"] def test_install_into_bare_project(self, tmp_path): """Install into a project with .specify/ but no integration.""" @@ -236,6 +397,7 @@ def test_uninstall_preserves_modified_files(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 assert "preserved" in result.output + assert ".claude/skills/speckit-plan/SKILL.md" in result.output # Modified file kept assert plan_file.exists() @@ -250,7 +412,68 @@ def test_uninstall_wrong_key(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code != 0 - assert "not the currently installed" in result.output + assert "not installed" in result.output + + def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path): + project = _init_project(tmp_path, "claude") + _write_invalid_manifest(project, "claude") + + 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 "manifest" in result.output + assert "unreadable" in result.output + + def test_uninstall_non_default_preserves_default(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "uninstall", "codex", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert data["installed_integrations"] == ["claude"] + + def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path): + project = _init_project(tmp_path, "gemini") + template = project / ".specify" / "templates" / "plan-template.md" + assert "/speckit.plan" in template.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert "/speckit-plan" in template.read_text(encoding="utf-8") def test_uninstall_preserves_shared_infra(self, tmp_path): """Shared scripts and templates are not removed by integration uninstall.""" @@ -271,6 +494,135 @@ def test_uninstall_preserves_shared_infra(self, tmp_path): assert (project / ".specify" / "templates").is_dir() +class TestIntegrationUse: + def test_use_installed_integration_sets_default(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "codex" + assert data["default_integration"] == "codex" + assert data["installed_integrations"] == ["claude", "codex"] + + opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert opts["integration"] == "codex" + assert opts["ai"] == "codex" + + def test_use_requires_installed_integration(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "use", "codex"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "not installed" in result.output + + def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path): + project = _init_project(tmp_path, "claude") + template = project / ".specify" / "templates" / "plan-template.md" + assert "/speckit-plan" in template.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "gemini", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) + assert use_gemini.exit_code == 0, use_gemini.output + assert "/speckit.plan" in template.read_text(encoding="utf-8") + + use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False) + assert use_claude.exit_code == 0, use_claude.output + assert "/speckit-plan" in template.read_text(encoding="utf-8") + finally: + os.chdir(old_cwd) + + def test_use_preserves_modified_templates_unless_forced(self, tmp_path): + project = _init_project(tmp_path, "claude") + template = project / ".specify" / "templates" / "plan-template.md" + template.write_text("custom template with /speckit-plan\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "gemini", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) + assert use_gemini.exit_code == 0, use_gemini.output + assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n" + + force_use = runner.invoke(app, [ + "integration", "use", "gemini", + "--force", + ], catch_exceptions=False) + assert force_use.exit_code == 0, force_use.output + finally: + os.chdir(old_cwd) + + updated = template.read_text(encoding="utf-8") + assert "/speckit.plan" in updated + assert "custom template" not in updated + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_use_does_not_persist_default_when_template_refresh_fails(self, tmp_path): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + init_options = project / ".specify" / "init-options.json" + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + before_state = json.loads(int_json.read_text(encoding="utf-8")) + before_options = json.loads(init_options.read_text(encoding="utf-8")) + + outside = tmp_path / "outside-template.md" + outside.write_text("# outside\n", encoding="utf-8") + template = project / ".specify" / "templates" / "plan-template.md" + template.unlink() + os.symlink(outside, template) + + result = runner.invoke(app, [ + "integration", "use", "codex", + "--force", + ]) + finally: + os.chdir(old_cwd) + + assert result.exit_code != 0 + assert "Failed to refresh shared templates" in result.output + assert json.loads(int_json.read_text(encoding="utf-8")) == before_state + assert json.loads(init_options.read_text(encoding="utf-8")) == before_options + assert outside.read_text(encoding="utf-8") == "# outside\n" + + # ── switch ─────────────────────────────────────────────────────────── @@ -296,6 +648,22 @@ def test_switch_unknown_target(self, tmp_path): assert result.exit_code != 0 assert "Unknown integration" in result.output + def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path): + project = _init_project(tmp_path, "claude") + _write_invalid_manifest(project, "claude") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "codex", + "--script", "sh", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Could not read integration manifest" in result.output + def test_switch_same_noop(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() @@ -305,7 +673,48 @@ def test_switch_same_noop(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - assert "already installed" in result.output + assert "already the default integration" in result.output + + def test_switch_same_force_refreshes_shared_templates(self, tmp_path): + project = _init_project(tmp_path, "claude") + template = project / ".specify" / "templates" / "plan-template.md" + template.write_text("# custom shared template\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "claude", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "managed shared templates refreshed" in result.output + assert "/speckit-plan" in template.read_text(encoding="utf-8") + + def test_switch_installed_target_rejects_integration_options(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "switch", "codex", + "--integration-options", "--bogus", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "--integration-options cannot be used" in result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["default_integration"] == "claude" def test_switch_between_integrations(self, tmp_path): project = _init_project(tmp_path, "claude") @@ -334,6 +743,142 @@ def test_switch_between_integrations(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "copilot" + def test_switch_migrates_extension_commands(self, tmp_path): + """Switching should migrate extension commands to the new agent directory.""" + project = _init_project(tmp_path, "kimi") + + # Install the bundled git extension + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + # Verify git extension skills exist for kimi + kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md" + assert kimi_git_feature.exists(), "Git extension skill should exist for kimi" + + result = _run_in_project(project, [ + "integration", "switch", "opencode", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + # Git extension commands should exist for opencode + opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md" + assert opencode_git_feature.exists(), "Git extension command should exist for opencode" + + # Old kimi extension skills should be removed + assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed" + + # Extension registry should be updated + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + registered_commands = registry["extensions"]["git"]["registered_commands"] + assert "opencode" in registered_commands + assert "kimi" not in registered_commands + + # Switch to claude + result = _run_in_project(project, [ + "integration", "switch", "claude", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + # Git extension skills should exist for claude + claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + assert claude_git_feature.exists(), "Git extension skill should exist for claude" + + # Old opencode extension commands should be removed + assert not opencode_git_feature.exists(), "Old opencode extension command should be removed" + + # Extension registry should be updated + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + registered_commands = registry["extensions"]["git"]["registered_commands"] + assert "claude" in registered_commands + assert "opencode" not in registered_commands + + def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path): + """Copilot --skills should receive extension skills, not .agent.md files.""" + project = _init_project(tmp_path, "opencode") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + result = _run_in_project(project, [ + "integration", "switch", "copilot", + "--script", "sh", + "--integration-options", "--skills", + ]) + assert result.exit_code == 0, result.output + + copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md" + assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode" + assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files" + + # Verify Copilot-specific frontmatter: mode field should map from + # skill name (speckit-git-feature) back to dot notation (speckit.git-feature) + skill_content = copilot_git_feature.read_text(encoding="utf-8") + assert "mode: speckit.git-feature" in skill_content, ( + "Copilot skill frontmatter should contain mode mapped from skill name" + ) + + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + git_meta = registry["extensions"]["git"] + assert "speckit-git-feature" in git_meta["registered_skills"] + assert "copilot" not in git_meta["registered_commands"] + + result = _run_in_project(project, [ + "integration", "switch", "opencode", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md" + assert opencode_git_feature.exists(), "Git extension command should exist for opencode" + assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed" + + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + git_meta = registry["extensions"]["git"] + assert git_meta["registered_skills"] == [] + assert "opencode" in git_meta["registered_commands"] + assert "copilot" not in git_meta["registered_commands"] + + def test_switch_does_not_register_disabled_extensions(self, tmp_path): + """Disabled extensions should stay disabled and should not migrate commands.""" + project = _init_project(tmp_path, "opencode") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + result = _run_in_project(project, ["extension", "disable", "git"]) + assert result.exit_code == 0, result.output + + opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md" + assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch" + + result = _run_in_project(project, [ + "integration", "switch", "claude", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent" + assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch" + + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + git_meta = registry["extensions"]["git"] + assert git_meta["enabled"] is False + assert "claude" not in git_meta["registered_commands"] + assert "opencode" not in git_meta["registered_commands"] + def test_switch_preserves_shared_infra(self, tmp_path): """Switching preserves shared scripts, templates, and memory.""" project = _init_project(tmp_path, "claude") @@ -376,6 +921,107 @@ def test_switch_from_nothing(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "claude" + def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "switch", "generic", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "codex" + assert data["installed_integrations"] == ["codex"] + + opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert opts["integration"] == "codex" + assert opts["ai"] == "codex" + + template = project / ".specify" / "templates" / "plan-template.md" + assert "/speckit-plan" in template.read_text(encoding="utf-8") + + +class TestIntegrationUpgrade: + def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): + project = _init_project(tmp_path, "claude") + _write_invalid_manifest(project, "claude") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "manifest" in result.output + assert "unreadable" in result.output + + def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + init_options = project / ".specify" / "init-options.json" + manifest_path = project / ".specify" / "integrations" / "claude.manifest.json" + + before_state = json.loads(int_json.read_text(encoding="utf-8")) + before_options = json.loads(init_options.read_text(encoding="utf-8")) + before_manifest = manifest_path.read_text(encoding="utf-8") + + import specify_cli + + def fail_refresh(*args, **kwargs): + raise ValueError("refuse refresh") + + monkeypatch.setattr(specify_cli, "_refresh_shared_templates", fail_refresh) + + result = _run_in_project(project, [ + "integration", "upgrade", "claude", + "--force", + ]) + + assert result.exit_code != 0 + assert "Failed to refresh shared templates" in result.output + assert json.loads(int_json.read_text(encoding="utf-8")) == before_state + assert json.loads(init_options.read_text(encoding="utf-8")) == before_options + assert manifest_path.read_text(encoding="utf-8") == before_manifest + + def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path): + project = _init_project(tmp_path, "gemini") + template = project / ".specify" / "templates" / "plan-template.md" + assert "/speckit.plan" in template.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "upgrade", "claude", + "--script", "sh", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "gemini" + assert "/speckit.plan" in template.read_text(encoding="utf-8") + # ── Full lifecycle ─────────────────────────────────────────────────── diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index ea6dc85a88..bab4539f1e 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -1,11 +1,38 @@ """Tests for VibeIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +import yaml +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest -class TestVibeIntegration(MarkdownIntegrationTests): +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestVibeIntegration(SkillsIntegrationTests): KEY = "vibe" FOLDER = ".vibe/" - COMMANDS_SUBDIR = "prompts" - REGISTRAR_DIR = ".vibe/prompts" - CONTEXT_FILE = ".vibe/agents/specify-agents.md" + 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_manifest.py b/tests/integrations/test_manifest.py index b5d5bc39f5..596397d4f7 100644 --- a/tests/integrations/test_manifest.py +++ b/tests/integrations/test_manifest.py @@ -2,6 +2,7 @@ import hashlib import json +import sys import pytest @@ -41,8 +42,9 @@ def test_record_file_rejects_parent_traversal(self, tmp_path): 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("/tmp/escape.txt", "bad") + m.record_file(abs_path, "bad") def test_record_existing_rejects_parent_traversal(self, tmp_path): escape = tmp_path.parent / "escape.txt" diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 8ab1425148..1b36501056 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -1,7 +1,13 @@ """Tests for INTEGRATION_REGISTRY — mechanics, completeness, and registrar alignment.""" +import json +import os +from pathlib import PurePosixPath + import pytest +from typer.testing import CliRunner +from specify_cli import app from specify_cli.integrations import ( INTEGRATION_REGISTRY, _register, @@ -25,6 +31,72 @@ ] +def _multi_install_safe_keys() -> list[str]: + return sorted( + key + for key, integration in INTEGRATION_REGISTRY.items() + if integration.multi_install_safe + ) + + +def _multi_install_safe_pairs() -> list[tuple[str, str]]: + safe_keys = _multi_install_safe_keys() + return [ + (safe_keys[left], safe_keys[right]) + for left in range(len(safe_keys)) + for right in range(left + 1, len(safe_keys)) + ] + + +def _posix_path(value: str | None) -> str | None: + if not value: + return None + return PurePosixPath(value).as_posix() + + +def _integration_root_dir(key: str) -> str | None: + integration = INTEGRATION_REGISTRY[key] + cfg = integration.config if isinstance(integration.config, dict) else {} + return _posix_path(cfg.get("folder")) + + +def _integration_commands_dir(key: str) -> str | None: + integration = INTEGRATION_REGISTRY[key] + cfg = integration.config if isinstance(integration.config, dict) else {} + folder = cfg.get("folder") + if not folder: + return None + subdir = cfg.get("commands_subdir", "commands") + return (PurePosixPath(folder) / subdir).as_posix() + + +def _paths_overlap(first: str | None, second: str | None) -> bool: + if not first or not second: + return False + left = PurePosixPath(first) + right = PurePosixPath(second) + try: + left.relative_to(right) + return True + except ValueError: + pass + try: + right.relative_to(left) + return True + except ValueError: + return False + + +def _path_is_inside(path: str | None, directory: str | None) -> bool: + if not path or not directory: + return False + try: + PurePosixPath(path).relative_to(PurePosixPath(directory)) + return True + except ValueError: + return False + + class TestRegistry: def test_registry_is_dict(self): assert isinstance(INTEGRATION_REGISTRY, dict) @@ -85,3 +157,134 @@ 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 + + +class TestMultiInstallSafeContracts: + """Declared safe integrations must stay isolated from each other.""" + + @pytest.mark.parametrize("key", _multi_install_safe_keys()) + def test_safe_integrations_have_static_isolated_paths(self, key): + integration = INTEGRATION_REGISTRY[key] + + assert _integration_root_dir(key), ( + f"{key} is declared multi-install safe but has no static root directory" + ) + assert _integration_commands_dir(key), ( + f"{key} is declared multi-install safe but has no static commands directory" + ) + assert integration.context_file, ( + f"{key} is declared multi-install safe but has no context file" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_integrations_have_distinct_agent_roots(self, first, second): + assert not _paths_overlap(_integration_root_dir(first), _integration_root_dir(second)), ( + f"{first} and {second} are declared multi-install safe but have " + f"overlapping agent roots {_integration_root_dir(first)!r} and " + f"{_integration_root_dir(second)!r}" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_integrations_have_distinct_command_dirs(self, first, second): + assert not _paths_overlap(_integration_commands_dir(first), _integration_commands_dir(second)), ( + f"{first} and {second} are declared multi-install safe but have " + f"overlapping command directories {_integration_commands_dir(first)!r} and " + f"{_integration_commands_dir(second)!r}" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_integrations_have_distinct_context_files(self, first, second): + first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) + second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) + + assert first_context != second_context, ( + f"{first} and {second} are declared multi-install safe but share " + f"context file {first_context!r}" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_context_files_do_not_overlap_other_agent_roots(self, first, second): + first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) + second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) + + assert not _path_is_inside(first_context, _integration_root_dir(second)), ( + f"{first} context file {first_context!r} lives under {second} " + f"agent root {_integration_root_dir(second)!r}" + ) + assert not _path_is_inside(second_context, _integration_root_dir(first)), ( + f"{second} context file {second_context!r} lives under {first} " + f"agent root {_integration_root_dir(first)!r}" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_context_files_do_not_overlap_other_command_dirs(self, first, second): + first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) + second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) + + assert not _path_is_inside(first_context, _integration_commands_dir(second)), ( + f"{first} context file {first_context!r} lives under {second} " + f"commands directory {_integration_commands_dir(second)!r}" + ) + assert not _path_is_inside(second_context, _integration_commands_dir(first)), ( + f"{second} context file {second_context!r} lives under {first} " + f"commands directory {_integration_commands_dir(first)!r}" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_integrations_have_disjoint_manifests( + self, + tmp_path, + first, + second, + ): + for initial, additional in ((first, second), (second, first)): + project_root = tmp_path / f"project-{initial}-{additional}" + project_root.mkdir() + runner = CliRunner() + + original_cwd = os.getcwd() + try: + os.chdir(project_root) + init_result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + initial, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + assert init_result.exit_code == 0, init_result.output + + install_result = runner.invoke( + app, + ["integration", "install", additional, "--script", "sh"], + catch_exceptions=False, + ) + assert install_result.exit_code == 0, install_result.output + finally: + os.chdir(original_cwd) + + initial_manifest = json.loads( + ( + project_root / ".specify" / "integrations" / f"{initial}.manifest.json" + ).read_text(encoding="utf-8") + ) + additional_manifest = json.loads( + ( + project_root / ".specify" / "integrations" / f"{additional}.manifest.json" + ).read_text(encoding="utf-8") + ) + + initial_files = set(initial_manifest.get("files", {})) + additional_files = set(additional_manifest.get("files", {})) + + assert initial_files.isdisjoint(additional_files), ( + f"{initial} and {additional} are declared multi-install safe but both manage " + f"these files: {sorted(initial_files & additional_files)}" + ) diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 35d8c02f7e..2f0fe15127 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -1,12 +1,10 @@ """Consistency checks for agent configuration across runtime surfaces.""" -import re 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 @@ -50,22 +48,17 @@ def test_init_ai_help_includes_roo_and_kiro_alias(self): 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 + 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 - def test_agent_context_scripts_use_kiro_cli(self): - """Agent context scripts should advertise kiro-cli and not legacy q agent key.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") - - assert "kiro-cli" in bash_text - assert "kiro-cli" in pwsh_text - assert "Amazon Q Developer CLI" not in bash_text - assert "Amazon Q Developer CLI" not in pwsh_text - # --- Tabnine CLI consistency checks --- def test_runtime_config_includes_tabnine(self): @@ -87,16 +80,6 @@ def test_extension_registrar_includes_tabnine(self): assert cfg["args"] == "{{args}}" assert cfg["extension"] == ".toml" - def test_agent_context_scripts_include_tabnine(self): - """Agent context scripts should support tabnine agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") - - assert "tabnine" in bash_text - assert "TABNINE_FILE" in bash_text - assert "tabnine" in pwsh_text - assert "TABNINE_FILE" in pwsh_text - def test_ai_help_includes_tabnine(self): """CLI help text for --ai should include tabnine.""" assert "tabnine" in AI_ASSISTANT_HELP @@ -119,16 +102,6 @@ def test_kimi_in_extension_registrar(self): assert kimi_cfg["dir"] == ".kimi/skills" assert kimi_cfg["extension"] == "/SKILL.md" - def test_kimi_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'kimi' in ValidateSet.""" - ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "kimi" in validate_set_values - def test_ai_help_includes_kimi(self): """CLI help text for --ai should include kimi.""" assert "kimi" in AI_ASSISTANT_HELP @@ -153,26 +126,6 @@ def test_trae_in_extension_registrar(self): assert trae_cfg["args"] == "$ARGUMENTS" assert trae_cfg["extension"] == "/SKILL.md" - def test_trae_in_agent_context_scripts(self): - """Agent context scripts should support trae agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") - - assert "trae" in bash_text - assert "TRAE_FILE" in bash_text - assert "trae" in pwsh_text - assert "TRAE_FILE" in pwsh_text - - def test_trae_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'trae' in ValidateSet.""" - ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "trae" in validate_set_values - def test_ai_help_includes_trae(self): """CLI help text for --ai should include trae.""" assert "trae" in AI_ASSISTANT_HELP @@ -198,26 +151,6 @@ def test_pi_in_extension_registrar(self): assert pi_cfg["args"] == "$ARGUMENTS" assert pi_cfg["extension"] == ".md" - def test_pi_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'pi' in ValidateSet.""" - ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "pi" in validate_set_values - - def test_agent_context_scripts_include_pi(self): - """Agent context scripts should support pi agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") - - assert "pi" in bash_text - assert "Pi Coding Agent" in bash_text - assert "pi" in pwsh_text - assert "Pi Coding Agent" in pwsh_text - def test_ai_help_includes_pi(self): """CLI help text for --ai should include pi.""" assert "pi" in AI_ASSISTANT_HELP @@ -240,16 +173,113 @@ def test_iflow_in_extension_registrar(self): assert cfg["iflow"]["format"] == "markdown" assert cfg["iflow"]["args"] == "$ARGUMENTS" - def test_iflow_in_agent_context_scripts(self): - """Agent context scripts should support iflow agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") - - assert "iflow" in bash_text - assert "IFLOW_FILE" in bash_text - assert "iflow" in pwsh_text - assert "IFLOW_FILE" in pwsh_text - 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 + + # --- invoke_separator propagation checks --- + + def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self): + """Skills-based agents must expose invoke_separator='-' in AGENT_CONFIGS. + + SkillsIntegration sets ``invoke_separator = "-"`` as a class attribute, + but individual skills integrations (claude, codex, …) do not repeat it in + their ``registrar_config`` dicts. ``_build_agent_configs()`` must + propagate the class attribute so that ``register_commands()`` resolves + ``__SPECKIT_COMMAND_*__`` tokens with the correct hyphen separator. + """ + cfg = CommandRegistrar.AGENT_CONFIGS + skills_agents = [ + key for key, c in cfg.items() if c.get("extension") == "/SKILL.md" + ] + assert skills_agents, ( + "Expected at least one skills-based agent in AGENT_CONFIGS" + ) + for agent in skills_agents: + assert cfg[agent].get("invoke_separator") == "-", ( + f"Skills agent '{agent}' has invoke_separator=" + f"{cfg[agent].get('invoke_separator')!r} in AGENT_CONFIGS; " + "expected '-' (propagated from SkillsIntegration.invoke_separator)" + ) + + def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path): + """__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit- + when registered for a skills-based agent (e.g. claude). + + Regression guard: before the fix, _build_agent_configs() did not + propagate invoke_separator from the integration class, so + register_commands() fell back to '.' and emitted /speckit.specify instead + of /speckit-specify for skills agents. + """ + import re + from pathlib import Path + + from specify_cli.agents import CommandRegistrar + + repo_root = Path(__file__).resolve().parent.parent + ext_dir = repo_root / "extensions" / "git" + cmd_source = ext_dir / "commands" / "speckit.git.feature.md" + assert cmd_source.exists(), ( + f"Git extension command source not found at {cmd_source}" + ) + assert "__SPECKIT_COMMAND_SPECIFY__" in cmd_source.read_text( + encoding="utf-8" + ), ( + "Expected __SPECKIT_COMMAND_SPECIFY__ token in speckit.git.feature.md; " + "check that the file uses the token rather than a hard-coded ref." + ) + + registrar = CommandRegistrar() + commands = [ + {"name": "speckit.git.feature", "file": "commands/speckit.git.feature.md"} + ] + + registered = registrar.register_commands( + "claude", + commands, + "git", + ext_dir, + tmp_path, + ) + + assert "speckit.git.feature" in registered + skill_file = ( + tmp_path / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + ) + assert skill_file.exists(), ( + f"Expected Claude skill file not found at {skill_file}" + ) + content = skill_file.read_text(encoding="utf-8") + assert "/speckit-specify" in content, ( + "Expected '/speckit-specify' (hyphen) in generated Claude skill for git.feature; " + "__SPECKIT_COMMAND_SPECIFY__ was not resolved with the correct separator." + ) + # Negative lookbehind (?) in generated Claude skill. " + "Skills agents must use hyphen notation." + ) diff --git a/tests/test_authentication.py b/tests/test_authentication.py new file mode 100644 index 0000000000..938cb87650 --- /dev/null +++ b/tests/test_authentication.py @@ -0,0 +1,860 @@ +"""Tests for the authentication provider registry and config-driven HTTP helpers. + +Covers: +- Config loading (auth.json parsing, validation, permission warning) +- Registry mechanics (_register, get_provider, duplicate/empty-key guards) +- GitHubAuth — bearer headers +- AzureDevOpsAuth — basic-pat, bearer, azure-cli, azure-ad headers +- Host matching (find_entries_for_url) +- open_url — config-driven auth with fallthrough and redirect stripping +- build_request — single-shot request construction +- _fetch_latest_release_tag() delegation +""" + +from __future__ import annotations + +import base64 +import json +import os + +import pytest + +from specify_cli.authentication import AUTH_REGISTRY, _register, get_provider +from specify_cli.authentication.azure_devops import AzureDevOpsAuth +from specify_cli.authentication.base import AuthProvider +from specify_cli.authentication.config import ( + AuthConfigEntry, + find_entries_for_url, + load_auth_config, +) +from specify_cli.authentication.github import GitHubAuth + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> AuthConfigEntry: + """Build a standard GitHub config entry.""" + return AuthConfigEntry( + hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"), + provider="github", + auth="bearer", + token=token, + token_env=token_env if token is None else None, + ) + + +def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry: + """Build an ADO basic-pat config entry.""" + return AuthConfigEntry( + hosts=("dev.azure.com",), + provider="azure-devops", + auth="basic-pat", + token_env=token_env, + ) + + +class _StubProvider(AuthProvider): + """Minimal concrete provider for registry mechanics tests.""" + + key = "stub-provider" + supported_auth_schemes = ("bearer",) + + def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# Config loading +# --------------------------------------------------------------------------- + + +class TestLoadAuthConfig: + def test_missing_file_returns_empty(self, tmp_path): + assert load_auth_config(tmp_path / "nonexistent.json") == [] + + def test_valid_github_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["github.com"], + "provider": "github", + "auth": "bearer", + "token_env": "GH_TOKEN", + }] + })) + entries = load_auth_config(cfg) + assert len(entries) == 1 + assert entries[0].provider == "github" + assert entries[0].auth == "bearer" + assert entries[0].token_env == "GH_TOKEN" + + def test_valid_ado_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "basic-pat", + "token_env": "AZURE_DEVOPS_PAT", + }] + })) + entries = load_auth_config(cfg) + assert len(entries) == 1 + assert entries[0].provider == "azure-devops" + assert entries[0].auth == "basic-pat" + + def test_inline_token(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["github.com"], + "provider": "github", + "auth": "bearer", + "token": "ghp_inline_token", + }] + })) + entries = load_auth_config(cfg) + assert entries[0].token == "ghp_inline_token" + + def test_azure_ad_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "azure-ad", + "tenant_id": "tid", + "client_id": "cid", + "client_secret_env": "SECRET", + }] + })) + entries = load_auth_config(cfg) + assert entries[0].auth == "azure-ad" + assert entries[0].tenant_id == "tid" + + def test_azure_cli_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "azure-cli", + }] + })) + entries = load_auth_config(cfg) + assert entries[0].auth == "azure-cli" + + def test_multiple_entries(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [ + {"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}, + {"hosts": ["dev.azure.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "ADO_PAT"}, + ] + })) + entries = load_auth_config(cfg) + assert len(entries) == 2 + + # -- Negative: validation errors -- + + def test_invalid_json_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text("not json") + with pytest.raises(json.JSONDecodeError): + load_auth_config(cfg) + + def test_not_object_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text("[]") + with pytest.raises(ValueError, match="JSON object"): + load_auth_config(cfg) + + def test_missing_providers_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({"foo": "bar"})) + with pytest.raises(ValueError, match="providers"): + load_auth_config(cfg) + + def test_empty_hosts_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": [], "provider": "github", "auth": "bearer", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="non-empty"): + load_auth_config(cfg) + + def test_missing_provider_key_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["github.com"], "auth": "bearer", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="provider"): + load_auth_config(cfg) + + def test_unsupported_auth_scheme_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["github.com"], "provider": "github", "auth": "ntlm", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="does not support"): + load_auth_config(cfg) + + def test_bearer_without_token_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer"}] + })) + with pytest.raises(ValueError, match="token"): + load_auth_config(cfg) + + def test_azure_ad_missing_fields_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "azure-ad", + "tenant_id": "tid", + }] + })) + with pytest.raises(ValueError, match="azure-ad"): + load_auth_config(cfg) + + def test_unknown_provider_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["example.com"], "provider": "gitlab", "auth": "bearer", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="unknown provider"): + load_auth_config(cfg) + + def test_incompatible_provider_scheme_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["github.com"], + "provider": "github", + "auth": "basic-pat", + "token_env": "X", + }] + })) + with pytest.raises(ValueError, match="does not support"): + load_auth_config(cfg) + + def test_dangerous_wildcard_host_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["*github.com"], "provider": "github", "auth": "bearer", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="invalid host pattern"): + load_auth_config(cfg) + + def test_multi_wildcard_host_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["*.*.example.com"], "provider": "github", "auth": "bearer", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="invalid host pattern"): + load_auth_config(cfg) + + def test_valid_star_dot_host_accepted(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["*.visualstudio.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "X"}] + })) + entries = load_auth_config(cfg) + assert entries[0].hosts == ("*.visualstudio.com",) + + @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits not supported on Windows") + def test_world_readable_warns(self, tmp_path): + import stat + + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}] + })) + cfg.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + with pytest.warns(UserWarning, match="readable by group"): + load_auth_config(cfg) + + +# --------------------------------------------------------------------------- +# Host matching +# --------------------------------------------------------------------------- + + +class TestFindEntriesForUrl: + def test_exact_match(self): + entry = _github_entry() + result = find_entries_for_url("https://github.com/org/repo", [entry]) + assert result == [entry] + + def test_wildcard_match(self): + entry = AuthConfigEntry( + hosts=("*.visualstudio.com",), + provider="azure-devops", + auth="basic-pat", + token_env="ADO_PAT", + ) + result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry]) + assert result == [entry] + + def test_no_match_returns_empty(self): + entry = _github_entry() + result = find_entries_for_url("https://evil.example.com/file", [entry]) + assert result == [] + + def test_no_match_for_lookalike_host(self): + entry = _github_entry() + result = find_entries_for_url("https://github.com.evil.com/file", [entry]) + assert result == [] + + def test_empty_url_returns_empty(self): + assert find_entries_for_url("", [_github_entry()]) == [] + + def test_empty_entries_returns_empty(self): + assert find_entries_for_url("https://github.com/org/repo", []) == [] + + def test_multiple_matches_returned(self): + e1 = _github_entry(token_env="GH_TOKEN") + e2 = _github_entry(token_env="GITHUB_TOKEN") + result = find_entries_for_url("https://github.com/org/repo", [e1, e2]) + assert len(result) == 2 + + +# --------------------------------------------------------------------------- +# Registry mechanics +# --------------------------------------------------------------------------- + + +class TestAuthRegistry: + def test_github_registered(self): + assert "github" in AUTH_REGISTRY + + def test_azure_devops_registered(self): + assert "azure-devops" in AUTH_REGISTRY + + def test_get_provider_returns_github(self): + assert isinstance(get_provider("github"), GitHubAuth) + + def test_get_provider_returns_azure_devops(self): + assert isinstance(get_provider("azure-devops"), AzureDevOpsAuth) + + def test_get_provider_unknown_returns_none(self): + assert get_provider("does-not-exist") is None + + def test_register_duplicate_raises_key_error(self): + class _UniqueStub(_StubProvider): + key = "__test_duplicate__" + + try: + _register(_UniqueStub()) + with pytest.raises(KeyError, match="already registered"): + _register(_UniqueStub()) + finally: + AUTH_REGISTRY.pop("__test_duplicate__", None) + + def test_register_empty_key_raises_value_error(self): + class _EmptyKey(_StubProvider): + key = "" + + with pytest.raises(ValueError, match="empty key"): + _register(_EmptyKey()) + + +# --------------------------------------------------------------------------- +# GitHubAuth +# --------------------------------------------------------------------------- + + +class TestGitHubAuth: + def test_bearer_headers(self): + assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"} + + def test_unsupported_scheme_raises(self): + with pytest.raises(ValueError, match="basic-pat"): + GitHubAuth().auth_headers("tok", "basic-pat") + + def test_resolve_token_from_env(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", "env-token") + assert GitHubAuth().resolve_token(_github_entry()) == "env-token" + + def test_resolve_token_inline(self): + assert GitHubAuth().resolve_token(_github_entry(token="inline-tok")) == "inline-tok" + + def test_resolve_token_strips_whitespace(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", " my-token ") + assert GitHubAuth().resolve_token(_github_entry()) == "my-token" + + def test_resolve_token_empty_env_returns_none(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", " ") + assert GitHubAuth().resolve_token(_github_entry()) is None + + def test_resolve_token_missing_env_returns_none(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + assert GitHubAuth().resolve_token(_github_entry()) is None + + def test_key(self): + assert GitHubAuth.key == "github" + + def test_supported_schemes(self): + assert GitHubAuth.supported_auth_schemes == ("bearer",) + + +# --------------------------------------------------------------------------- +# AzureDevOpsAuth +# --------------------------------------------------------------------------- + + +class TestAzureDevOpsAuth: + def test_basic_pat_headers(self): + headers = AzureDevOpsAuth().auth_headers("my-pat", "basic-pat") + encoded = base64.b64encode(b":my-pat").decode("ascii") + assert headers == {"Authorization": f"Basic {encoded}"} + + def test_basic_pat_format(self): + header = AzureDevOpsAuth().auth_headers("test-pat", "basic-pat")["Authorization"] + raw = base64.b64decode(header[len("Basic "):]).decode("ascii") + assert raw == ":test-pat" + + def test_bearer_headers(self): + assert AzureDevOpsAuth().auth_headers("tok", "bearer") == {"Authorization": "Bearer tok"} + + def test_azure_cli_headers(self): + assert AzureDevOpsAuth().auth_headers("tok", "azure-cli") == {"Authorization": "Bearer tok"} + + def test_azure_ad_headers(self): + assert AzureDevOpsAuth().auth_headers("tok", "azure-ad") == {"Authorization": "Bearer tok"} + + def test_unsupported_scheme_raises(self): + with pytest.raises(ValueError): + AzureDevOpsAuth().auth_headers("tok", "ntlm") + + def test_resolve_token_basic_pat(self, monkeypatch): + monkeypatch.setenv("AZURE_DEVOPS_PAT", "my-pat") + assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat" + + def test_resolve_token_strips_whitespace(self, monkeypatch): + monkeypatch.setenv("AZURE_DEVOPS_PAT", " my-pat ") + assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat" + + def test_resolve_token_missing_returns_none(self, monkeypatch): + monkeypatch.delenv("AZURE_DEVOPS_PAT", raising=False) + assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) is None + + def test_key(self): + assert AzureDevOpsAuth.key == "azure-devops" + + def test_supported_schemes(self): + schemes = AzureDevOpsAuth.supported_auth_schemes + assert "basic-pat" in schemes + assert "bearer" in schemes + assert "azure-cli" in schemes + assert "azure-ad" in schemes + + def test_resolve_token_azure_cli_success(self): + """azure-cli acquires token via az CLI.""" + from unittest.mock import patch, MagicMock + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + result = MagicMock() + result.returncode = 0 + result.stdout = '{"accessToken": "cli-acquired-token"}' + with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result): + assert AzureDevOpsAuth().resolve_token(entry) == "cli-acquired-token" + + def test_resolve_token_azure_cli_failure_returns_none(self): + """azure-cli returns None when az CLI fails.""" + from unittest.mock import patch, MagicMock + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + result = MagicMock() + result.returncode = 1 + result.stdout = "" + with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result): + assert AzureDevOpsAuth().resolve_token(entry) is None + + def test_resolve_token_azure_cli_not_installed_returns_none(self): + """azure-cli returns None when az is not installed.""" + from unittest.mock import patch + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")): + assert AzureDevOpsAuth().resolve_token(entry) is None + + def test_resolve_token_azure_ad_success(self, monkeypatch): + """azure-ad acquires token via OAuth2 client credentials.""" + from unittest.mock import patch, MagicMock + monkeypatch.setenv("MY_SECRET", "secret-value") + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad", + tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET", + ) + mock_resp = MagicMock() + mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}' + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + with patch("urllib.request.urlopen", return_value=mock_resp): + assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token" + + def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch): + """azure-ad returns None when client secret env var is missing.""" + monkeypatch.delenv("MY_SECRET", raising=False) + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad", + tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET", + ) + assert AzureDevOpsAuth().resolve_token(entry) is None + + def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch): + """azure-ad returns None on network errors.""" + import urllib.error + from unittest.mock import patch + monkeypatch.setenv("MY_SECRET", "secret-value") + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad", + tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET", + ) + with patch("urllib.request.urlopen", + side_effect=urllib.error.URLError("connection refused")): + assert AzureDevOpsAuth().resolve_token(entry) is None + + +# --------------------------------------------------------------------------- +# open_url / build_request — positive tests +# --------------------------------------------------------------------------- + + +class TestAuthenticatedHttp: + def _set_config(self, monkeypatch, entries): + from specify_cli.authentication import http as _mod + monkeypatch.setattr(_mod, "_config_override", entries) + + def test_build_request_attaches_auth_for_matching_host(self, monkeypatch): + from specify_cli.authentication.http import build_request + monkeypatch.setenv("GH_TOKEN", "my-token") + self._set_config(monkeypatch, [_github_entry()]) + req = build_request("https://github.com/org/repo") + assert req.get_header("Authorization") == "Bearer my-token" + + def test_build_request_no_auth_for_non_matching_host(self, monkeypatch): + from specify_cli.authentication.http import build_request + monkeypatch.setenv("GH_TOKEN", "my-token") + self._set_config(monkeypatch, [_github_entry()]) + req = build_request("https://evil.example.com/file") + assert "Authorization" not in req.headers + + def test_build_request_no_auth_when_no_config(self, monkeypatch): + from specify_cli.authentication.http import build_request + self._set_config(monkeypatch, []) + req = build_request("https://github.com/org/repo") + assert "Authorization" not in req.headers + + def test_build_request_extra_headers(self, monkeypatch): + from specify_cli.authentication.http import build_request + monkeypatch.setenv("GH_TOKEN", "my-token") + self._set_config(monkeypatch, [_github_entry()]) + req = build_request("https://github.com/api", extra_headers={"Accept": "application/json"}) + assert req.get_header("Accept") == "application/json" + assert req.get_header("Authorization") == "Bearer my-token" + + def test_open_url_attaches_auth_for_matching_host(self, monkeypatch): + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + monkeypatch.setenv("GH_TOKEN", "my-token") + self._set_config(monkeypatch, [_github_entry()]) + captured = {} + mock_opener = MagicMock() + def fake_open(req, timeout=None): + captured["req"] = req + resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + return resp + mock_opener.open.side_effect = fake_open + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + open_url("https://github.com/org/repo/catalog.json") + assert captured["req"].get_header("Authorization") == "Bearer my-token" + + def test_open_url_no_auth_for_non_matching_host(self, monkeypatch): + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + monkeypatch.setenv("GH_TOKEN", "my-token") + self._set_config(monkeypatch, [_github_entry()]) + captured = {} + def fake_urlopen(req, timeout=None): + captured["req"] = req + resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + return resp + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen): + open_url("https://example.com/file.json") + assert captured["req"].get_header("Authorization") is None + + def test_open_url_no_auth_when_no_config(self, monkeypatch): + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + self._set_config(monkeypatch, []) + captured = {} + def fake_urlopen(req, timeout=None): + captured["req"] = req + resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + return resp + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen): + open_url("https://github.com/org/repo") + assert captured["req"].get_header("Authorization") is None + + def test_open_url_falls_through_on_401(self, monkeypatch): + import urllib.error + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + monkeypatch.setenv("GH_TOKEN", "bad-token") + self._set_config(monkeypatch, [_github_entry()]) + call_count = 0 + def fake_side_effect(req, timeout=None): + nonlocal call_count; call_count += 1 + if call_count == 1: + raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None) + resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + return resp + mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \ + patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect): + open_url("https://github.com/org/repo") + assert call_count == 2 + + +# --------------------------------------------------------------------------- +# open_url — negative tests +# --------------------------------------------------------------------------- + + +class TestAuthenticatedHttpNegative: + def _set_config(self, monkeypatch, entries): + from specify_cli.authentication import http as _mod + monkeypatch.setattr(_mod, "_config_override", entries) + + def test_500_raises_immediately(self, monkeypatch): + import urllib.error + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + monkeypatch.setenv("GH_TOKEN", "tok") + self._set_config(monkeypatch, [_github_entry()]) + mock_opener = MagicMock() + mock_opener.open.side_effect = urllib.error.HTTPError("url", 500, "ISE", {}, None) + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + with pytest.raises(urllib.error.HTTPError, match="500"): + open_url("https://github.com/org/repo") + + def test_404_raises_immediately(self, monkeypatch): + import urllib.error + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + monkeypatch.setenv("GH_TOKEN", "tok") + self._set_config(monkeypatch, [_github_entry()]) + mock_opener = MagicMock() + mock_opener.open.side_effect = urllib.error.HTTPError("url", 404, "Not Found", {}, None) + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + with pytest.raises(urllib.error.HTTPError, match="404"): + open_url("https://github.com/org/repo") + + def test_urlerror_propagates(self, monkeypatch): + import urllib.error + from unittest.mock import patch + from specify_cli.authentication.http import open_url + self._set_config(monkeypatch, []) + with patch("specify_cli.authentication.http.urllib.request.urlopen", + side_effect=urllib.error.URLError("refused")): + with pytest.raises(urllib.error.URLError): + open_url("https://example.com/file") + + def test_timeout_propagates(self, monkeypatch): + import socket + from unittest.mock import patch + from specify_cli.authentication.http import open_url + self._set_config(monkeypatch, []) + with patch("specify_cli.authentication.http.urllib.request.urlopen", + side_effect=socket.timeout("timed out")): + with pytest.raises(socket.timeout): + open_url("https://example.com/file") + + +# --------------------------------------------------------------------------- +# _load_config caching +# --------------------------------------------------------------------------- + + +class TestLoadConfigCaching: + def test_config_cached_after_first_load(self, monkeypatch): + """_load_config() should call load_auth_config only once per process.""" + from unittest.mock import patch + from specify_cli.authentication import http as _mod + from specify_cli.authentication.config import AuthConfigEntry + # Allow the real load path (no override) + monkeypatch.setattr(_mod, "_config_override", None) + monkeypatch.setattr(_mod, "_config_cache", None) + + entry = _github_entry() + call_count = 0 + + def fake_load(path=None): + nonlocal call_count + call_count += 1 + return [entry] + + with patch.object(_mod, "load_auth_config", side_effect=fake_load): + _mod._load_config() + _mod._load_config() + _mod._load_config() + + assert call_count == 1 + + def test_cache_bypassed_by_override(self, monkeypatch): + """When _config_override is set, the cache is ignored entirely.""" + from specify_cli.authentication import http as _mod + sentinel = [_github_entry()] + monkeypatch.setattr(_mod, "_config_override", sentinel) + monkeypatch.setattr(_mod, "_config_cache", None) + + result = _mod._load_config() + assert result is sentinel + # Cache must not have been populated when override is active + assert _mod._config_cache is None + + def test_failed_load_warns_once_and_caches_empty(self, monkeypatch): + """A bad auth.json emits exactly one warning and subsequent calls use cache.""" + from unittest.mock import patch + from specify_cli.authentication import http as _mod + import warnings as _warnings + monkeypatch.setattr(_mod, "_config_override", None) + monkeypatch.setattr(_mod, "_config_cache", None) + + call_count = 0 + + def fail_load(path=None): + nonlocal call_count + call_count += 1 + raise ValueError("bad config") + + with patch.object(_mod, "load_auth_config", side_effect=fail_load): + with _warnings.catch_warnings(record=True) as w: + _warnings.simplefilter("always") + result1 = _mod._load_config() + result2 = _mod._load_config() + result3 = _mod._load_config() + + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 1, "Expected exactly one warning" + # Loader called only once — subsequent calls used cache + assert call_count == 1 + # All calls returned the cached empty list + assert result1 == result2 == result3 == [] + + +# --------------------------------------------------------------------------- +# Redirect stripping +# --------------------------------------------------------------------------- + + +class TestRedirectStripping: + def test_redirect_within_hosts_preserves_auth(self): + from specify_cli.authentication.http import _StripAuthOnRedirect + from urllib.request import Request + import io + handler = _StripAuthOnRedirect(("github.com", "codeload.github.com")) + req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"}) + new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {}, + "https://codeload.github.com/org/repo/zip") + assert new_req is not None + auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization") + assert auth == "Bearer tok" + + def test_redirect_outside_hosts_strips_auth(self): + from specify_cli.authentication.http import _StripAuthOnRedirect + from urllib.request import Request + import io + handler = _StripAuthOnRedirect(("github.com",)) + req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"}) + new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {}, + "https://objects.githubusercontent.com/asset") + assert new_req is not None + assert new_req.headers.get("Authorization") is None + assert new_req.unredirected_hdrs.get("Authorization") is None + + def test_multi_hop_redirect_within_hosts_preserves_auth(self): + """Auth survives a multi-hop redirect chain within allowed hosts.""" + from specify_cli.authentication.http import _StripAuthOnRedirect + from urllib.request import Request + import io + hosts = ("github.com", "codeload.github.com", "objects-origin.githubusercontent.com") + handler = _StripAuthOnRedirect(hosts) + + # First hop: github.com → codeload.github.com + req1 = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"}) + req2 = handler.redirect_request(req1, io.BytesIO(b""), 302, "Found", {}, + "https://codeload.github.com/org/repo/zip") + assert req2 is not None + auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get("Authorization") + assert auth2 == "Bearer tok" + + # Second hop: codeload.github.com → objects-origin.githubusercontent.com + req3 = handler.redirect_request(req2, io.BytesIO(b""), 302, "Found", {}, + "https://objects-origin.githubusercontent.com/asset") + assert req3 is not None + auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization") + assert auth3 == "Bearer tok" + + +# --------------------------------------------------------------------------- +# _fetch_latest_release_tag delegation +# --------------------------------------------------------------------------- + + +class TestFetchLatestReleaseTagDelegation: + def _set_config(self, monkeypatch, entries): + from specify_cli.authentication import http as _mod + monkeypatch.setattr(_mod, "_config_override", entries) + + def _capture_request(self): + import json as _json + from unittest.mock import MagicMock + captured: dict = {} + def side_effect(req, timeout=None): + captured["request"] = req + body = _json.dumps({"tag_name": "v9.9.9"}).encode() + resp = MagicMock(); resp.read.return_value = body + cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False + return cm + return captured, side_effect + + def test_gh_token_forwarded_when_configured(self, monkeypatch): + from unittest.mock import MagicMock, patch + from specify_cli import _fetch_latest_release_tag + monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel") + self._set_config(monkeypatch, [_github_entry()]) + captured, side_effect = self._capture_request() + mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + _fetch_latest_release_tag() + assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel" + + def test_no_config_means_no_auth(self, monkeypatch): + from unittest.mock import patch + from specify_cli import _fetch_latest_release_tag + self._set_config(monkeypatch, []) + captured, side_effect = self._capture_request() + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + assert captured["request"].get_header("Authorization") is None + + def test_accept_header_present(self, monkeypatch): + from unittest.mock import patch + from specify_cli import _fetch_latest_release_tag + self._set_config(monkeypatch, []) + captured, side_effect = self._capture_request() + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + assert captured["request"].get_header("Accept") == "application/vnd.github+json" 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_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py deleted file mode 100644 index d9d0e34237..0000000000 --- a/tests/test_cursor_frontmatter.py +++ /dev/null @@ -1,263 +0,0 @@ -""" -Tests for Cursor .mdc frontmatter generation (issue #669). - -Verifies that update-agent-context.sh properly prepends YAML frontmatter -to .mdc files so that Cursor IDE auto-includes the rules. -""" - -import os -import shutil -import subprocess -import textwrap - -import pytest - -SCRIPT_PATH = os.path.join( - os.path.dirname(__file__), - os.pardir, - "scripts", - "bash", - "update-agent-context.sh", -) - -EXPECTED_FRONTMATTER_LINES = [ - "---", - "description: Project Development Guidelines", - 'globs: ["**/*"]', - "alwaysApply: true", - "---", -] - -requires_git = pytest.mark.skipif( - shutil.which("git") is None, - reason="git is not installed", -) - - -class TestScriptFrontmatterPattern: - """Static analysis — no git required.""" - - def test_create_new_has_mdc_frontmatter_logic(self): - """create_new_agent_file() must contain .mdc frontmatter logic.""" - with open(SCRIPT_PATH, encoding="utf-8") as f: - content = f.read() - assert 'if [[ "$target_file" == *.mdc ]]' in content - assert "alwaysApply: true" in content - - def test_update_existing_has_mdc_frontmatter_logic(self): - """update_existing_agent_file() must also handle .mdc frontmatter.""" - with open(SCRIPT_PATH, encoding="utf-8") as f: - content = f.read() - # There should be two occurrences of the .mdc check — one per function - occurrences = content.count('if [[ "$target_file" == *.mdc ]]') - assert occurrences >= 2, ( - f"Expected at least 2 .mdc frontmatter checks, found {occurrences}" - ) - - def test_powershell_script_has_mdc_frontmatter_logic(self): - """PowerShell script must also handle .mdc frontmatter.""" - ps_path = os.path.join( - os.path.dirname(__file__), - os.pardir, - "scripts", - "powershell", - "update-agent-context.ps1", - ) - with open(ps_path, encoding="utf-8") as f: - content = f.read() - assert "alwaysApply: true" in content - occurrences = content.count(r"\.mdc$") - assert occurrences >= 2, ( - f"Expected at least 2 .mdc frontmatter checks in PS script, found {occurrences}" - ) - - -@requires_git -class TestCursorFrontmatterIntegration: - """Integration tests using a real git repo.""" - - @pytest.fixture - def git_repo(self, tmp_path): - """Create a minimal git repo with the spec-kit structure.""" - repo = tmp_path / "repo" - repo.mkdir() - - # Init git repo - subprocess.run( - ["git", "init"], cwd=str(repo), capture_output=True, check=True - ) - subprocess.run( - ["git", "config", "user.email", "test@test.com"], - cwd=str(repo), - capture_output=True, - check=True, - ) - subprocess.run( - ["git", "config", "user.name", "Test"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create .specify dir with config - specify_dir = repo / ".specify" - specify_dir.mkdir() - (specify_dir / "config.yaml").write_text( - textwrap.dedent("""\ - project_type: webapp - language: python - framework: fastapi - database: N/A - """) - ) - - # Create template - templates_dir = specify_dir / "templates" - templates_dir.mkdir() - (templates_dir / "agent-file-template.md").write_text( - "# [PROJECT NAME] Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: [DATE]\n\n" - "## Active Technologies\n\n" - "[EXTRACTED FROM ALL PLAN.MD FILES]\n\n" - "## Project Structure\n\n" - "[ACTUAL STRUCTURE FROM PLANS]\n\n" - "## Development Commands\n\n" - "[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]\n\n" - "## Coding Conventions\n\n" - "[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]\n\n" - "## Recent Changes\n\n" - "[LAST 3 FEATURES AND WHAT THEY ADDED]\n" - ) - - # Create initial commit - subprocess.run( - ["git", "add", "-A"], cwd=str(repo), capture_output=True, check=True - ) - subprocess.run( - ["git", "commit", "-m", "init"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create a feature branch so CURRENT_BRANCH detection works - subprocess.run( - ["git", "checkout", "-b", "001-test-feature"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create a spec so the script detects the feature - spec_dir = repo / "specs" / "001-test-feature" - spec_dir.mkdir(parents=True) - (spec_dir / "plan.md").write_text( - "# Test Feature Plan\n\n" - "## Technology Stack\n\n" - "- Language: Python\n" - "- Framework: FastAPI\n" - ) - - return repo - - def _run_update(self, repo, agent_type="cursor-agent"): - """Run update-agent-context.sh for a specific agent type.""" - script = os.path.abspath(SCRIPT_PATH) - result = subprocess.run( - ["bash", script, agent_type], - cwd=str(repo), - capture_output=True, - text=True, - timeout=30, - ) - return result - - def test_new_mdc_file_has_frontmatter(self, git_repo): - """Creating a new .mdc file must include YAML frontmatter.""" - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - mdc_file = git_repo / ".cursor" / "rules" / "specify-rules.mdc" - assert mdc_file.exists(), "Cursor .mdc file was not created" - - content = mdc_file.read_text() - lines = content.splitlines() - - # First line must be the opening --- - assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" - - # Check all frontmatter lines are present - for expected in EXPECTED_FRONTMATTER_LINES: - assert expected in content, f"Missing frontmatter line: {expected}" - - # Content after frontmatter should be the template content - assert "Development Guidelines" in content - - def test_existing_mdc_without_frontmatter_gets_it_added(self, git_repo): - """Updating an existing .mdc file that lacks frontmatter must add it.""" - # First, create the file WITHOUT frontmatter (simulating pre-fix state) - cursor_dir = git_repo / ".cursor" / "rules" - cursor_dir.mkdir(parents=True, exist_ok=True) - mdc_file = cursor_dir / "specify-rules.mdc" - mdc_file.write_text( - "# repo Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" - "## Active Technologies\n\n" - "- Python + FastAPI (main)\n\n" - "## Recent Changes\n\n" - "- main: Added Python + FastAPI\n" - ) - - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - content = mdc_file.read_text() - lines = content.splitlines() - - assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" - for expected in EXPECTED_FRONTMATTER_LINES: - assert expected in content, f"Missing frontmatter line: {expected}" - - def test_existing_mdc_with_frontmatter_not_duplicated(self, git_repo): - """Updating an .mdc file that already has frontmatter must not duplicate it.""" - cursor_dir = git_repo / ".cursor" / "rules" - cursor_dir.mkdir(parents=True, exist_ok=True) - mdc_file = cursor_dir / "specify-rules.mdc" - - frontmatter = ( - "---\n" - "description: Project Development Guidelines\n" - 'globs: ["**/*"]\n' - "alwaysApply: true\n" - "---\n\n" - ) - body = ( - "# repo Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" - "## Active Technologies\n\n" - "- Python + FastAPI (main)\n\n" - "## Recent Changes\n\n" - "- main: Added Python + FastAPI\n" - ) - mdc_file.write_text(frontmatter + body) - - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - content = mdc_file.read_text() - # Count occurrences of the frontmatter delimiter - assert content.count("alwaysApply: true") == 1, ( - "Frontmatter was duplicated" - ) - - def test_non_mdc_file_has_no_frontmatter(self, git_repo): - """Non-.mdc agent files (e.g., Claude) must NOT get frontmatter.""" - result = self._run_update(git_repo, agent_type="claude") - assert result.returncode == 0, f"Script failed: {result.stderr}" - - claude_file = git_repo / ".claude" / "CLAUDE.md" - if claude_file.exists(): - content = claude_file.read_text() - assert not content.startswith("---"), ( - "Non-mdc file should not have frontmatter" - ) diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 8a9f19e74e..89e8b4a8b8 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -269,7 +269,7 @@ def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir): assert isinstance(parsed, dict) assert parsed["name"] == "speckit-test-ext-hello" assert "description" in parsed - assert parsed["disable-model-invocation"] is True + 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.""" @@ -396,11 +396,8 @@ def test_skill_registration_resolves_script_placeholders(self, project_dir, temp "description: Scripted plan command\n" "scripts:\n" " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n" - "agent_scripts:\n" - " sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n" "---\n\n" "Run {SCRIPT}\n" - "Then {AGENT_SCRIPT}\n" "Review templates/checklist.md and memory/constitution.md for __AGENT__.\n" ) @@ -409,11 +406,9 @@ def test_skill_registration_resolves_script_placeholders(self, project_dir, temp content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text() assert "{SCRIPT}" not in content - assert "{AGENT_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/scripts/bash/update-agent-context.sh claude" in content assert ".specify/templates/checklist.md" in content assert ".specify/memory/constitution.md" in content diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9d4df6a9a1..1434ba309d 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -11,6 +11,7 @@ import pytest import json +import platform import tempfile import shutil import tomllib @@ -216,6 +217,43 @@ 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_utf8_non_ascii_description_loads(self, temp_dir, valid_manifest_data): + """Regression for #2325: non-ASCII (UTF-8) description loads on any platform. + + On Windows, Python's default text-mode encoding is the locale codepage + (e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes + outside the ASCII range. The loader must open with encoding='utf-8'. + """ + import yaml + + valid_manifest_data["extension"]["description"] = "中文测试 — émojis 🚀" + manifest_path = temp_dir / "extension.yml" + # Write UTF-8 bytes explicitly so the test exercises the read path, + # not the (locale-dependent) write path. + manifest_path.write_bytes( + yaml.safe_dump(valid_manifest_data, allow_unicode=True).encode("utf-8") + ) + + manifest = ExtensionManifest(manifest_path) + assert manifest.description == "中文测试 — émojis 🚀" + + def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir): + """Negative case: file containing invalid UTF-8 bytes raises ValidationError, not raw UnicodeDecodeError.""" + manifest_path = temp_dir / "extension.yml" + # 0xFF/0xFE are not valid UTF-8 lead bytes. + manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n") + + with pytest.raises(ValidationError, match="not valid UTF-8"): + ExtensionManifest(manifest_path) + def test_invalid_extension_id(self, temp_dir, valid_manifest_data): """Test manifest with invalid extension ID format.""" import yaml @@ -243,7 +281,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" @@ -255,6 +293,83 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="Invalid command name"): ExtensionManifest(manifest_path) + 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 @@ -317,6 +432,19 @@ def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data): 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): """Test manifest hash calculation.""" manifest_path = extension_dir / "extension.yml" @@ -686,8 +814,8 @@ def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_ with pytest.raises(ValidationError, match="conflicts with core command namespace"): manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - def test_install_rejects_alias_without_extension_namespace(self, temp_dir, project_dir): - """Install should reject legacy short aliases that can shadow core commands.""" + 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 = temp_dir / "alias-shortcut" @@ -718,8 +846,10 @@ def test_install_rejects_alias_without_extension_namespace(self, temp_dir, proje (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") manager = ExtensionManager(project_dir) - with pytest.raises(ValidationError, match="Invalid alias 'speckit.shortcut'"): - manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + assert manifest.commands[0]["aliases"] == ["speckit.shortcut"] + assert manifest.warnings == [] def test_install_rejects_namespace_squatting(self, temp_dir, project_dir): """Install should reject commands and aliases outside the extension namespace.""" @@ -1241,13 +1371,9 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} Agent __AGENT__ """ ) @@ -1268,11 +1394,82 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir content = skill_file.read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content assert "__AGENT__" not in content assert "{ARGS}" not in content assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh codex" in content + + @pytest.mark.parametrize("agent_name,skills_path", [ + ("codex", ".agents/skills"), + ("kimi", ".kimi/skills"), + ("claude", ".claude/skills"), + ("cursor-agent", ".cursor/skills"), + ("trae", ".trae/skills"), + ("agy", ".agents/skills"), + ]) + def test_all_skill_agents_register_commands_with_resolved_placeholders( + self, project_dir, temp_dir, agent_name, skills_path + ): + """All SKILL.md agents must produce fully resolved SKILL.md files when commands are registered.""" + import yaml + + ext_dir = temp_dir / f"ext-{agent_name}" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": f"ext-{agent_name}", + "name": "Scripted Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": f"speckit.ext-{agent_name}.run", + "file": "commands/run.md", + "description": "Scripted command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Scripted command\n" + "scripts:\n" + ' sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"\n' + "---\n\n" + "Run {SCRIPT}\n" + "Agent is __AGENT__.\n" + ) + + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(f'{{"ai":"{agent_name}","script":"sh"}}') + + skills_dir = project_dir + for part in skills_path.split("/"): + skills_dir = skills_dir / part + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent(agent_name, manifest, ext_dir, project_dir) + + skill_dir_name = f"speckit-ext-{agent_name}-run" + skill_file = skills_dir / skill_dir_name / "SKILL.md" + assert skill_file.exists(), f"SKILL.md not created for {agent_name}" + + content = skill_file.read_text() + assert "{SCRIPT}" not in content, f"{{SCRIPT}} not resolved for {agent_name}" + assert "__AGENT__" not in content, f"__AGENT__ not resolved for {agent_name}" + assert "{ARGS}" not in content, f"{{ARGS}} not resolved for {agent_name}" + assert '.specify/scripts/bash/setup-plan.sh' in content def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir): """Codex alias skills should render their own matching `name:` frontmatter.""" @@ -1358,12 +1555,9 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} """ ) @@ -1380,9 +1574,10 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti content = skill_file.read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content - assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh codex" in content + if platform.system().lower().startswith("win"): + assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content + else: + assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content def test_codex_skill_registration_handles_non_dict_init_options( self, project_dir, temp_dir @@ -1479,13 +1674,9 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} """ ) @@ -1501,7 +1692,6 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( content = skill_file.read_text() assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content - assert ".specify/scripts/powershell/update-agent-context.ps1 -AgentType codex" in content assert ".specify/scripts/bash/setup-plan.sh" not in content def test_register_commands_for_copilot(self, extension_dir, project_dir): @@ -1619,6 +1809,54 @@ def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir): prompts_dir = project_dir / ".github" / "prompts" assert not prompts_dir.exists() + def test_unregister_skill_removes_parent_directory(self, project_dir, temp_dir): + """Unregistering a SKILL.md command should remove the empty parent subdirectory.""" + import yaml + + ext_dir = temp_dir / "cleanup-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "cleanup-ext", + "name": "Cleanup Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.cleanup-ext.run", + "file": "commands/run.md", + "description": "Run", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + (ext_dir / "commands" / "run.md").write_text("---\ndescription: Run\n---\n\nBody") + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + registrar = CommandRegistrar() + from specify_cli.extensions import ExtensionManifest + manifest = ExtensionManifest(ext_dir / "extension.yml") + registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + skill_subdir = skills_dir / "speckit-cleanup-ext-run" + assert skill_subdir.exists(), "Skill subdirectory should exist after registration" + assert (skill_subdir / "SKILL.md").exists() + + registrar.unregister_commands({"codex": ["speckit.cleanup-ext.run"]}, project_dir) + + assert not (skill_subdir / "SKILL.md").exists(), "SKILL.md should be removed" + assert not skill_subdir.exists(), "Empty parent subdirectory should be removed" + # ===== Utility Function Tests ===== @@ -2207,6 +2445,181 @@ def test_clear_cache(self, temp_dir): assert not catalog.cache_file.exists() assert not catalog.cache_metadata_file.exists() + # --- _make_request / GitHub auth --- + + def _make_catalog(self, temp_dir): + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + return ExtensionCatalog(project_dir) + + def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"): + from tests.auth_helpers import inject_github_config + inject_github_config(monkeypatch, token_env) + + def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch): + """Without a token, requests carry no Authorization header.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_whitespace_only_github_token_ignored(self, temp_dir, monkeypatch): + """A whitespace-only GITHUB_TOKEN is treated as unset.""" + monkeypatch.setenv("GITHUB_TOKEN", " ") + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, temp_dir, monkeypatch): + """When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback.""" + monkeypatch.setenv("GITHUB_TOKEN", " ") + monkeypatch.setenv("GH_TOKEN", "ghp_fallback") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert req.get_header("Authorization") == "Bearer ghp_fallback" + + def test_make_request_github_token_added_for_raw_githubusercontent(self, temp_dir, monkeypatch): + """GITHUB_TOKEN is attached for raw.githubusercontent.com URLs.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + monkeypatch.delenv("GH_TOKEN", raising=False) + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_make_request_gh_token_fallback(self, temp_dir, monkeypatch): + """GH_TOKEN is used when GITHUB_TOKEN is absent.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip") + assert req.get_header("Authorization") == "Bearer ghp_ghtoken" + + def test_make_request_gh_token_takes_precedence_over_github_token(self, temp_dir, monkeypatch): + """When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary") + monkeypatch.setenv("GH_TOKEN", "ghp_primary") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://api.github.com/repos/org/repo") + assert req.get_header("Authorization") == "Bearer ghp_primary" + + def test_make_request_no_auth_for_non_matching_host(self, temp_dir, monkeypatch): + """Auth is NOT attached to hosts not listed in auth.json.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://internal.example.com/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_no_auth_when_no_config(self, temp_dir, monkeypatch): + """No auth header when no auth.json config exists.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip") + assert "Authorization" not in req.headers + + def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch): + """GITHUB_TOKEN is attached for api.github.com URLs.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch): + """GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects).""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch): + """_fetch_single_catalog passes Authorization header when a provider is configured.""" + from unittest.mock import patch, MagicMock + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = self._make_catalog(temp_dir) + + catalog_data = {"schema_version": "1.0", "extensions": {}} + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(catalog_data).encode() + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/catalog.json" + + captured = {} + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured["req"] = req + return mock_response + + mock_opener.open.side_effect = fake_open + + entry = CatalogEntry( + url="https://raw.githubusercontent.com/org/repo/main/catalog.json", + name="private", + priority=1, + install_allowed=True, + ) + + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + catalog._fetch_single_catalog(entry, force_refresh=True) + + assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" + + def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch): + """download_extension passes Authorization header when a provider is configured.""" + from unittest.mock import patch, MagicMock + import zipfile, io + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = self._make_catalog(temp_dir) + + # Build a minimal valid ZIP in memory + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n") + zip_bytes = zip_buf.getvalue() + + mock_response = MagicMock() + mock_response.read.return_value = zip_bytes + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + captured = {} + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured["req"] = req + return mock_response + + mock_opener.open.side_effect = fake_open + + ext_info = { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "download_url": "https://github.com/org/repo/releases/download/v1/test-ext.zip", + } + + with patch.object(catalog, "get_extension_info", return_value=ext_info), \ + patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + catalog.download_extension("test-ext", target_dir=temp_dir) + + assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" + + # ===== CatalogEntry Tests ===== @@ -2995,6 +3408,122 @@ def mock_download(extension_id): f"but was called with '{download_called_with[0]}'" ) + def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path): + """extension add should give a clear error when a bundled extension is not found locally.""" + from typer.testing import CliRunner + from unittest.mock import patch, MagicMock + from specify_cli import app + + runner = CliRunner() + + # Create project structure + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + # Mock catalog that returns a bundled extension without download_url + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = { + "id": "git", + "name": "Git Branching Workflow", + "version": "1.0.0", + "description": "Git branching extension", + "bundled": True, + "_install_allowed": True, + } + mock_catalog.search.return_value = [] + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch("specify_cli._locate_bundled_extension", return_value=None), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "git"], + catch_exceptions=True, + ) + + assert result.exit_code != 0 + assert "bundled with spec-kit" in result.output + assert "reinstall" in result.output.lower() + + +class TestDownloadExtensionBundled: + """Tests for download_extension handling of bundled extensions.""" + + def test_download_extension_raises_for_bundled(self, temp_dir): + """download_extension should raise a clear error for bundled extensions without a URL.""" + from unittest.mock import patch + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + bundled_ext_info = { + "name": "Git Branching Workflow", + "id": "git", + "version": "1.0.0", + "description": "Git workflow", + "bundled": True, + } + + with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info): + with pytest.raises(ExtensionError, match="bundled with spec-kit"): + catalog.download_extension("git") + + def test_download_extension_allows_bundled_with_url(self, temp_dir): + """download_extension should allow bundled extensions that have a download_url (newer version).""" + from unittest.mock import patch, MagicMock + import urllib.request + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + bundled_with_url = { + "name": "Git Branching Workflow", + "id": "git", + "version": "2.0.0", + "description": "Git workflow", + "bundled": True, + "download_url": "https://example.com/git-2.0.0.zip", + } + + mock_response = MagicMock() + mock_response.read.return_value = b"fake zip data" + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \ + patch.object(urllib.request, "urlopen", return_value=mock_response): + result = catalog.download_extension("git") + assert result.name == "git-2.0.0.zip" + + def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir): + """download_extension should raise 'no download URL' for non-bundled extensions without URL.""" + from unittest.mock import patch + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + non_bundled_ext_info = { + "name": "Some Extension", + "id": "some-ext", + "version": "1.0.0", + "description": "Test", + } + + with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info): + with pytest.raises(ExtensionError, match="has no download URL"): + catalog.download_extension("some-ext") + class TestExtensionUpdateCLI: """CLI integration tests for extension update command.""" @@ -3737,3 +4266,58 @@ def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir): assert "Executing: `/`" in message assert "EXECUTE_COMMAND: " in message assert "EXECUTE_COMMAND_INVOCATION: /" in message + + +class TestExtensionRemoveCLI: + """CLI tests for `specify extension remove` confirmation prompt wording.""" + + def _install_ext(self, project_dir, ext_dir): + """Install extension and return the manager.""" + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + return manager + + def test_remove_confirmation_singular_command(self, tmp_path, extension_dir): + """Confirmation prompt should say '1 command' (singular) when one command registered.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manager = self._install_ext(project_dir, extension_dir) + # Inject registered_commands with 1 entry so cmd_count == 1 + manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello"]}}) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False + ) + + assert "1 command" in result.output + assert "1 commands" not in result.output + + def test_remove_confirmation_plural_commands(self, tmp_path, extension_dir): + """Confirmation prompt should say '2 commands' (plural) when two commands registered.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manager = self._install_ext(project_dir, extension_dir) + # Inject registered_commands with 2 entries so cmd_count == 2 + manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello", "speckit.test-ext.run"]}}) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False + ) + + assert "2 commands" in result.output diff --git a/tests/test_github_http.py b/tests/test_github_http.py new file mode 100644 index 0000000000..f414aeeb2b --- /dev/null +++ b/tests/test_github_http.py @@ -0,0 +1,79 @@ +"""Tests for GitHub-authenticated HTTP request helpers.""" + +import os +from unittest.mock import patch + +import pytest + +from specify_cli._github_http import ( + build_github_request, +) + + +class TestBuildGitHubRequest: + """Tests for build_github_request() URL validation and auth handling.""" + + # --- URL Validation Tests --- + + def test_empty_url_raises_value_error(self): + """build_github_request() must reject an empty string URL.""" + with pytest.raises(ValueError, match="url must not be empty"): + build_github_request("") + + def test_whitespace_url_raises_value_error(self): + """build_github_request() must reject a whitespace-only URL.""" + with pytest.raises(ValueError, match="url must not be empty"): + build_github_request(" ") + + def test_non_http_url_raises_value_error(self): + """build_github_request() must reject URLs without http/https scheme.""" + with pytest.raises(ValueError, match="url must start with http"): + build_github_request("not-a-url") + + def test_ftp_url_raises_value_error(self): + """build_github_request() must reject ftp:// URLs.""" + with pytest.raises(ValueError, match="url must start with http"): + build_github_request("ftp://github.com/file.zip") + + # --- Valid URL Tests --- + + def test_valid_https_url_returns_request(self): + """build_github_request() must return a Request for a valid https URL.""" + req = build_github_request("https://github.com/github/spec-kit") + assert req.full_url == "https://github.com/github/spec-kit" + + def test_valid_http_url_returns_request(self): + """build_github_request() must accept http:// URLs.""" + req = build_github_request("http://example.com/file") + assert req.full_url == "http://example.com/file" + + # --- Auth Header Tests --- + + def test_github_token_added_for_github_host(self): + """Authorization header is set when GITHUB_TOKEN is present.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token", "GH_TOKEN": ""}): + req = build_github_request("https://github.com/github/spec-kit") + assert req.get_header("Authorization") == "Bearer test-token" + + def test_gh_token_used_as_fallback(self): + """GH_TOKEN is used when GITHUB_TOKEN is absent.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "fallback-token"}): + req = build_github_request("https://github.com/github/spec-kit") + assert req.get_header("Authorization") == "Bearer fallback-token" + + def test_no_auth_header_for_non_github_host(self): + """Authorization header must NOT be set for non-GitHub URLs.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}): + req = build_github_request("https://example.com/file") + assert req.get_header("Authorization") is None + + def test_no_auth_header_when_no_token(self): + """No Authorization header when no token is set in environment.""" + with patch.dict(os.environ, {}, clear=True): + req = build_github_request("https://github.com/github/spec-kit") + assert req.get_header("Authorization") is None + + def test_missing_hostname_raises_value_error(self): + """build_github_request() must reject URLs with valid scheme but no hostname.""" + with pytest.raises(ValueError, match="url must include a hostname"): + build_github_request("http://") \ No newline at end of file diff --git a/tests/test_presets.py b/tests/test_presets.py index d22264f806..e5143b834b 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -14,6 +14,7 @@ import json import tempfile import shutil +import warnings import zipfile from pathlib import Path from datetime import datetime, timezone @@ -160,6 +161,38 @@ def test_invalid_yaml(self, temp_dir): with pytest.raises(PresetValidationError, match="Invalid YAML"): PresetManifest(bad_file) + def test_utf8_non_ascii_description_loads(self, temp_dir, valid_pack_data): + """Regression for #2325: non-ASCII (UTF-8) description loads on any platform. + + On Windows, Python's default text-mode encoding is the locale codepage + (e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes + outside the ASCII range. The loader must open with encoding='utf-8'. + """ + valid_pack_data["preset"]["description"] = "中文测试 — émojis 🚀" + manifest_path = temp_dir / "preset.yml" + manifest_path.write_bytes( + yaml.safe_dump(valid_pack_data, allow_unicode=True).encode("utf-8") + ) + + manifest = PresetManifest(manifest_path) + assert manifest.description == "中文测试 — émojis 🚀" + + def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir): + """Negative case: file containing invalid UTF-8 bytes raises PresetValidationError, not raw UnicodeDecodeError.""" + manifest_path = temp_dir / "preset.yml" + manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n") + + with pytest.raises(PresetValidationError, match="not valid UTF-8"): + PresetManifest(manifest_path) + + def test_non_mapping_yaml_raises_validation_error(self, temp_dir): + """Manifest whose YAML root is a scalar or list raises PresetValidationError, not TypeError.""" + manifest_path = temp_dir / "preset.yml" + for bad_content in ("42\n", "[1, 2]\n"): + manifest_path.write_text(bad_content, encoding="utf-8") + with pytest.raises(PresetValidationError, match="YAML mapping"): + PresetManifest(manifest_path) + def test_missing_schema_version(self, temp_dir, valid_pack_data): """Test missing schema_version field.""" del valid_pack_data["schema_version"] @@ -999,6 +1032,94 @@ def test_resolve_skips_hidden_extension_dirs(self, project_dir): assert result is None +class TestResolveCore: + """Test PresetResolver.resolve_core() skips the installed-presets tier.""" + + def test_resolve_core_does_not_return_preset_files(self, project_dir): + """resolve_core must not return files from .specify/presets/.""" + preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" + preset_cmd_dir.mkdir(parents=True) + (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + # The preset file must never be returned — but the bundled core may be. + if result is not None: + assert "presets" not in result.parts + + def test_resolve_core_returns_core_template(self, project_dir): + """resolve_core falls through to core templates (tier 4).""" + core_cmd_dir = project_dir / ".specify" / "templates" / "commands" + core_cmd_dir.mkdir(parents=True, exist_ok=True) + (core_cmd_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") + + # Also place a preset file — resolve_core must still return the core + preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" + preset_cmd_dir.mkdir(parents=True) + (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + assert result is not None + assert "presets" not in result.parts + assert result.parts[-3:] == ("templates", "commands", "specify.md") + + def test_resolve_core_returns_override(self, project_dir): + """resolve_core returns tier-1 override if present.""" + override_dir = project_dir / ".specify" / "templates" / "overrides" + override_dir.mkdir(parents=True) + (override_dir / "specify.md").write_text("---\ndescription: override\n---\n\noverride body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + assert result is not None + assert result.parts[-2:] == ("overrides", "specify.md") + + def test_resolve_core_returns_extension_template(self, project_dir): + """resolve_core returns extension templates (tier 3).""" + ext_cmd_dir = project_dir / ".specify" / "extensions" / "myext" / "commands" + ext_cmd_dir.mkdir(parents=True) + (ext_cmd_dir / "myext-cmd.md").write_text("---\ndescription: ext\n---\n\next body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("myext-cmd", "command") + assert result is not None + assert result.parts[-4:-1] == ("extensions", "myext", "commands") + + def test_resolve_core_returns_none_when_nothing_found(self, project_dir): + """resolve_core returns None when no file found in tiers 1/3/4.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("nonexistent", "command") + assert result is None + + def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir): + """resolve_extension_command_via_manifest skips extensions whose manifest raises OSError.""" + import unittest.mock as mock + + ext_dir = project_dir / ".specify" / "extensions" / "bad-ext" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "mycmd.md").write_text("---\ndescription: d\n---\n\nbody\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: bad-ext\n name: Bad\n version: 1.0.0\n" + " description: d\n author: a\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n commands:\n" + " - name: speckit.bad-ext.mycmd\n" + " file: commands/mycmd.md\n" + " description: My command\n" + ) + + resolver = PresetResolver(project_dir) + # Simulate a permission error when opening the manifest file. + with mock.patch("builtins.open", side_effect=PermissionError("denied")): + result = resolver.resolve_extension_command_via_manifest("speckit.bad-ext.mycmd") + + assert result is None, "OSError during manifest load must be silently skipped" + + class TestExtensionPriorityResolution: """Test extension priority resolution with registered and unregistered extensions.""" @@ -1103,6 +1224,10 @@ def test_same_priority_sorted_alphabetically(self, project_dir): class TestPresetCatalog: """Test template catalog functionality.""" + def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"): + from tests.auth_helpers import inject_github_config + inject_github_config(monkeypatch, token_env) + def test_default_catalog_url(self, project_dir): """Test default catalog URL.""" catalog = PresetCatalog(project_dir) @@ -1175,8 +1300,7 @@ def test_search_with_cached_data(self, project_dir, monkeypatch): """Test search with cached catalog data.""" from unittest.mock import patch - # Only use the default catalog to prevent fetching the community catalog from the network - monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", PresetCatalog.DEFAULT_CATALOG_URL) + monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False) catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) @@ -1276,6 +1400,162 @@ def test_env_var_catalog_url(self, project_dir, monkeypatch): catalog = PresetCatalog(project_dir) assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json" + # --- _make_request / GitHub auth --- + + def test_make_request_no_token_no_auth_header(self, project_dir, monkeypatch): + """Without a token, requests carry no Authorization header.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_whitespace_only_github_token_ignored(self, project_dir, monkeypatch): + """A whitespace-only GITHUB_TOKEN is treated as unset.""" + monkeypatch.setenv("GITHUB_TOKEN", " ") + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, project_dir, monkeypatch): + """When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback.""" + monkeypatch.setenv("GITHUB_TOKEN", " ") + monkeypatch.setenv("GH_TOKEN", "ghp_fallback") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert req.get_header("Authorization") == "Bearer ghp_fallback" + + def test_make_request_github_token_added_for_github_url(self, project_dir, monkeypatch): + """GITHUB_TOKEN is attached for raw.githubusercontent.com URLs.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + monkeypatch.delenv("GH_TOKEN", raising=False) + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_make_request_gh_token_fallback(self, project_dir, monkeypatch): + """GH_TOKEN is used when GITHUB_TOKEN is absent.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") + assert req.get_header("Authorization") == "Bearer ghp_ghtoken" + + def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch): + """When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary") + monkeypatch.setenv("GH_TOKEN", "ghp_primary") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://api.github.com/repos/org/repo") + assert req.get_header("Authorization") == "Bearer ghp_primary" + + def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch): + """GITHUB_TOKEN is attached for codeload.github.com URLs.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch): + """Auth is NOT attached to hosts not listed in auth.json.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://internal.example.com/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch): + """No auth header when no auth.json config exists.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") + assert "Authorization" not in req.headers + + def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch): + """_fetch_single_catalog passes Authorization header when configured.""" + from unittest.mock import patch, MagicMock + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + + catalog_data = {"schema_version": "1.0", "presets": {}} + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(catalog_data).encode() + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json" + + captured = {} + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured["req"] = req + return mock_response + + mock_opener.open.side_effect = fake_open + + entry = PresetCatalogEntry( + url="https://raw.githubusercontent.com/org/repo/main/presets/catalog.json", + name="private", + priority=1, + install_allowed=True, + ) + + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + catalog._fetch_single_catalog(entry, force_refresh=True) + + assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" + + def test_download_pack_sends_auth_header(self, project_dir, monkeypatch): + """download_pack passes Authorization header when configured.""" + from unittest.mock import patch, MagicMock + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + catalog = PresetCatalog(project_dir) + + import io + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") + zip_bytes = zip_buf.getvalue() + + mock_response = MagicMock() + mock_response.read.return_value = zip_bytes + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + captured = {} + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured["req"] = req + return mock_response + + mock_opener.open.side_effect = fake_open + + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://github.com/org/repo/releases/download/v1/test-pack.zip", + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + catalog.download_pack("test-pack", target_dir=project_dir) + + assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" + # ===== Integration Tests ===== @@ -1642,6 +1922,10 @@ def test_url_cache_expired(self, project_dir): SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test" +SELF_TEST_WRAP_WARNING = ( + r"Cannot compose command 'speckit\.wrap-test': no base layer\. " + r"Stale command files may remain\." +) CORE_TEMPLATE_NAMES = [ "spec-template", @@ -1649,10 +1933,21 @@ def test_url_cache_expired(self, project_dir): "tasks-template", "checklist-template", "constitution-template", - "agent-file-template", ] +def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1.5") -> PresetManifest: + """Install self-test while filtering its intentionally missing wrap base.""" + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=SELF_TEST_WRAP_WARNING, + category=UserWarning, + module=r"specify_cli\.presets", + ) + return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version) + + class TestSelfTestPreset: """Tests using the self-test preset that ships with the repo.""" @@ -1667,7 +1962,7 @@ def test_self_test_manifest_valid(self): assert manifest.id == "self-test" assert manifest.name == "Self-Test Preset" assert manifest.version == "1.0.0" - assert len(manifest.templates) == 7 # 6 templates + 1 command + assert len(manifest.templates) == 8 # 6 templates + 2 commands def test_self_test_provides_all_core_templates(self): """Verify the self-test preset provides an override for every core template.""" @@ -1693,7 +1988,7 @@ def test_self_test_templates_have_marker(self): def test_install_self_test_preset(self, project_dir): """Test installing the self-test preset from its directory.""" manager = PresetManager(project_dir) - manifest = manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + manifest = install_self_test_preset(manager) assert manifest.id == "self-test" assert manager.registry.is_installed("self-test") @@ -1706,7 +2001,7 @@ def test_self_test_overrides_all_core_templates(self, project_dir): # Install self-test preset manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) # Every core template should now resolve from the preset resolver = PresetResolver(project_dir) @@ -1725,7 +2020,7 @@ def test_self_test_resolve_with_source(self, project_dir): (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) resolver = PresetResolver(project_dir) for name in CORE_TEMPLATE_NAMES: @@ -1742,7 +2037,7 @@ def test_self_test_removal_restores_core(self, project_dir): (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) manager.remove("self-test") resolver = PresetResolver(project_dir) @@ -1778,7 +2073,7 @@ def test_self_test_registers_commands_for_claude(self, project_dir): claude_dir.mkdir(parents=True) manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) # Check the skill was registered cmd_file = claude_dir / "speckit-specify" / "SKILL.md" @@ -1794,7 +2089,7 @@ def test_self_test_registers_commands_for_gemini(self, project_dir): gemini_dir.mkdir(parents=True) manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) # Check the command was registered in TOML format cmd_file = gemini_dir / "speckit.specify.toml" @@ -1809,7 +2104,7 @@ def test_self_test_unregisters_commands_on_remove(self, project_dir): claude_dir.mkdir(parents=True) manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) cmd_file = claude_dir / "speckit-specify" / "SKILL.md" assert cmd_file.exists() @@ -1820,7 +2115,7 @@ def test_self_test_unregisters_commands_on_remove(self, project_dir): def test_self_test_no_commands_without_agent_dirs(self, project_dir): """Test that no commands are registered when no agent dirs exist.""" manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) metadata = manager.registry.get("self-test") assert metadata["registered_commands"] == {} @@ -1969,14 +2264,13 @@ def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): # Install self-test preset (has a command override for speckit.specify) manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert skill_file.exists() content = skill_file.read_text() assert "preset:self-test" in content, "Skill should reference preset source" - assert "disable-model-invocation: true" in content + assert "disable-model-invocation: false" in content # Verify it was recorded in registry metadata = manager.registry.get("self-test") @@ -1989,8 +2283,7 @@ def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): self._create_skill(skills_dir, "speckit-specify", body="untouched") manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" content = skill_file.read_text() @@ -2022,8 +2315,7 @@ def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): self._create_skill(skills_dir, "speckit-specify", body="untouched") manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" file_content = skill_file.read_text() @@ -2043,8 +2335,7 @@ def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): (core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n") manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) # Verify preset content is in the skill skill_file = skills_dir / "speckit-specify" / "SKILL.md" @@ -2058,7 +2349,7 @@ def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): content = skill_file.read_text() assert "preset:self-test" not in content, "Preset content should be gone" assert "templates/commands/specify.md" in content, "Should reference core template" - assert "disable-model-invocation: true" in content + assert "disable-model-invocation: false" in content def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir): """Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths.""" @@ -2080,8 +2371,7 @@ def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir ) manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) manager.remove("self-test") content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() @@ -2097,8 +2387,7 @@ def test_skill_not_overridden_when_skill_path_is_file(self, project_dir): (skills_dir / "speckit-specify").write_text("not-a-directory") manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) assert (skills_dir / "speckit-specify").is_file() metadata = manager.registry.get("self-test") @@ -2110,8 +2399,7 @@ def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_d # Don't create skills dir — simulate --ai-skills never created them manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) metadata = manager.registry.get("self-test") assert metadata.get("registered_skills", []) == [] @@ -2312,8 +2600,7 @@ def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) - self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(self_test_dir, "0.1.5") + install_self_test_preset(manager) skill_file = skills_dir / "speckit.specify" / "SKILL.md" assert skill_file.exists() @@ -2333,8 +2620,7 @@ def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) - self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(self_test_dir, "0.1.5") + install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert skill_file.exists() @@ -2449,7 +2735,7 @@ def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_d def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir): """Agy preset removal should restore native skills instead of deleting them.""" self._write_init_options(project_dir, ai="agy", ai_skills=True) - skills_dir = project_dir / ".agent" / "skills" + skills_dir = project_dir / ".agents" / "skills" self._create_skill(skills_dir, "speckit-specify", body="before override") core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md" @@ -2513,8 +2799,7 @@ def test_preset_skill_registration_handles_non_dict_init_options(self, project_d self._create_skill(skills_dir, "speckit-specify", body="untouched") manager = PresetManager(project_dir) - self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(self_test_dir, "0.1.5") + install_self_test_preset(manager) skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() assert "untouched" in skill_content @@ -2865,3 +3150,1451 @@ def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): assert result.exit_code == 1 assert "corrupted state" in result.output.lower() + + +# ===== Lean Preset Tests ===== + + +LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" + +LEAN_COMMAND_NAMES = [ + "speckit.specify", + "speckit.plan", + "speckit.tasks", + "speckit.implement", + "speckit.constitution", +] + + +class TestLeanPreset: + """Tests for the lean preset that ships with the repo.""" + + def test_lean_preset_exists(self): + """Verify the lean preset directory and manifest exist.""" + assert LEAN_PRESET_DIR.exists() + assert (LEAN_PRESET_DIR / "preset.yml").exists() + + def test_lean_manifest_valid(self): + """Verify the lean preset manifest is valid.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + assert manifest.id == "lean" + assert manifest.name == "Lean Workflow" + assert manifest.version == "1.0.0" + assert len(manifest.templates) == 5 # 5 commands + + def test_lean_provides_core_workflow_commands(self): + """Verify the lean preset provides overrides for core workflow commands.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + provided_names = {t["name"] for t in manifest.templates} + for name in LEAN_COMMAND_NAMES: + assert name in provided_names, f"Lean preset missing command: {name}" + + def test_lean_command_files_exist(self): + """Verify that all declared command files actually exist on disk.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + for tmpl in manifest.templates: + tmpl_path = LEAN_PRESET_DIR / tmpl["file"] + assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" + + def test_lean_commands_have_no_scripts(self): + """Verify lean commands have no scripts in frontmatter.""" + from specify_cli.agents import CommandRegistrar + + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + frontmatter, _ = CommandRegistrar.parse_frontmatter(content) + assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" + + def test_lean_commands_have_no_hooks(self): + """Verify lean commands do not contain extension hook boilerplate.""" + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + assert "hooks." not in content, f"{name} should not reference extension hooks" + assert "extensions.yml" not in content, f"{name} should not reference extensions.yml" + + def test_install_lean_preset(self, project_dir): + """Test installing the lean preset from its directory.""" + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") + assert manifest.id == "lean" + assert manager.registry.is_installed("lean") + + def test_lean_overrides_commands(self, project_dir): + """Test that lean preset overrides are resolved correctly.""" + manager = PresetManager(project_dir) + manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") + + resolver = PresetResolver(project_dir) + for name in LEAN_COMMAND_NAMES: + result = resolver.resolve(name, template_type="command") + assert result is not None, f"Lean override for {name} not resolved" + + +# ===== Bundled Preset Locator Tests ===== + + +class TestBundledPresetLocator: + """Tests for _locate_bundled_preset discovery function.""" + + def test_locate_bundled_lean_preset(self): + """_locate_bundled_preset finds the lean preset.""" + from specify_cli import _locate_bundled_preset + + path = _locate_bundled_preset("lean") + assert path is not None + assert (path / "preset.yml").is_file() + + def test_locate_bundled_preset_not_found(self): + """_locate_bundled_preset returns None for nonexistent preset.""" + from specify_cli import _locate_bundled_preset + + path = _locate_bundled_preset("nonexistent-preset") + assert path is None + + def test_locate_bundled_preset_rejects_invalid_id(self): + """_locate_bundled_preset rejects IDs with invalid characters.""" + from specify_cli import _locate_bundled_preset + + assert _locate_bundled_preset("../escape") is None + assert _locate_bundled_preset("UPPERCASE") is None + assert _locate_bundled_preset("has spaces") is None + + def test_bundled_preset_add_via_cli(self, project_dir): + """Test that 'specify preset add lean' installs the bundled preset.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.get_speckit_version", return_value="0.6.0"): + result = runner.invoke(app, ["preset", "add", "lean"]) + + assert result.exit_code == 0, result.output + assert "Lean Workflow" in result.output + assert "installed" in result.output.lower() + + def test_bundled_preset_in_catalog(self): + """Verify the lean preset is listed in catalog.json with bundled marker.""" + catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" + catalog = json.loads(catalog_path.read_text()) + assert "lean" in catalog["presets"] + assert catalog["presets"]["lean"]["bundled"] is True + assert "download_url" not in catalog["presets"]["lean"] + + def test_bundled_preset_download_raises_error(self, project_dir): + """download_pack raises PresetError for bundled presets without download_url.""" + catalog = PresetCatalog(project_dir) + + catalog_data = { + "test-bundled": { + "name": "Test Bundled", + "version": "1.0.0", + "bundled": True, + } + } + from unittest.mock import patch + with patch.object(catalog, "_get_merged_packs", return_value=catalog_data): + with pytest.raises(PresetError, match="bundled with spec-kit"): + catalog.download_pack("test-bundled") + + def test_bundled_preset_missing_locally_cli_error(self, project_dir): + """CLI shows clear error when bundled preset cannot be found locally.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + # Patch _locate_bundled_preset to return None (simulating missing files) + # and mock the catalog to return a bundled entry for "lean" + fake_pack_info = { + "id": "lean", + "name": "Lean Workflow", + "version": "1.0.0", + "bundled": True, + "_install_allowed": True, + } + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli._locate_bundled_preset", return_value=None), \ + patch("specify_cli.presets.PresetCatalog") as MockCatalog: + MockCatalog.return_value.get_pack_info.return_value = fake_pack_info + result = runner.invoke(app, ["preset", "add", "lean"]) + + # Should fail with a helpful error explaining this is a bundled preset + # and suggesting how to recover. + assert result.exit_code == 1 + output = strip_ansi(result.output).lower() + assert "bundled" in output, result.output + assert "reinstall" in output, result.output + + +class TestWrapStrategy: + """Tests for strategy: wrap preset command substitution.""" + + def test_substitute_core_template_replaces_placeholder(self, project_dir): + """Core template body replaces {CORE_TEMPLATE} in preset command body.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + # Set up a core command template + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n" + ) + + registrar = CommandRegistrar() + body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + assert "{CORE_TEMPLATE}" not in result + assert "# Core Specify" in result + assert "## Pre-Logic" in result + assert "## Post-Logic" in result + assert core_fm.get("description") == "core" + + def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir): + """Returns body unchanged when {CORE_TEMPLATE} is not present.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n") + + registrar = CommandRegistrar() + body = "## No placeholder here.\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + assert result == body + assert core_fm == {} + + def test_substitute_core_template_no_op_when_core_missing(self, project_dir): + """Returns body unchanged when core template file does not exist.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + registrar = CommandRegistrar() + body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" + result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar) + assert result == body + assert "{CORE_TEMPLATE}" in result + assert core_fm == {} + + def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir): + """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap.""" + from specify_cli.agents import CommandRegistrar + + # Set up core command template + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n" + ) + + # Create a preset command dir with a wrap-strategy command + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: wrap test\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}] + registrar = CommandRegistrar() + + # Use a generic agent that writes markdown to commands/ + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + # Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir + import copy + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-agent", commands, "test-preset", + project_dir / "preset", project_dir + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "# Core Specify" in written + assert "## Pre" in written + assert "## Post" in written + + def test_end_to_end_wrap_via_self_test_preset(self, project_dir): + """Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}.""" + from specify_cli.presets import PresetManager + + # Install a core template that wrap-test will wrap around + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "wrap-test.md").write_text( + "---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n" + ) + + # Set up skills dir (simulating --ai claude) + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + skill_subdir = skills_dir / "speckit-wrap-test" + skill_subdir.mkdir() + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n") + + # Write init-options so _register_skills finds the claude skills dir + import json + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + written = (skill_subdir / "SKILL.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "# Core Wrap-Test Body" in written + assert "preset:self-test wrap-pre" in written + assert "preset:self-test wrap-post" in written + + def test_substitute_core_template_returns_core_scripts(self, project_dir): + """core_frontmatter in the returned tuple includes scripts/agent_scripts.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n" + ) + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + assert "# Body" in result + assert core_fm.get("scripts") == {"sh": "run.sh"} + assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"} + + def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir): + """_register_skills merges scripts/agent_scripts from core when preset lacks them.""" + from specify_cli.presets import PresetManager + import json + + # Core template with scripts + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "wrap-test.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n" + "Run: {SCRIPT}\n" + ) + + # Skills dir for claude + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + skill_subdir = skills_dir / "speckit-wrap-test" + skill_subdir.mkdir() + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n") + + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager = PresetManager(project_dir) + install_self_test_preset(manager) + + written = (skill_subdir / "SKILL.md").read_text() + # {SCRIPT} should have been resolved (not left as a literal placeholder) + assert "{SCRIPT}" not in written + + def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir): + """preset-defined scripts/agent_scripts are not overwritten by core frontmatter.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n" + ) + + registrar = CommandRegistrar() + body = "{CORE_TEMPLATE}" + _, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + # Simulate preset frontmatter that already defines scripts + preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}} + for key in ("scripts", "agent_scripts"): + if key not in preset_fm and key in core_fm: + preset_fm[key] = core_fm[key] + + # Preset's scripts must not be overwritten by core + assert preset_fm["scripts"] == {"sh": "preset-run.sh"} + + def test_register_commands_inherits_scripts_from_core(self, project_dir): + """register_commands merges scripts/agent_scripts from core and normalizes paths.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + # Preset has strategy: wrap but no scripts of its own + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "Run:" in written + assert "scripts:" in written + assert "run.sh" in written + + def test_register_commands_toml_resolves_inherited_scripts(self, project_dir): + """TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: toml wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + toml_dir = project_dir / ".gemini" / "commands" + toml_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-toml-agent"] = { + "dir": str(toml_dir.relative_to(project_dir)), + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-toml-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (toml_dir / "speckit.specify.toml").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "{SCRIPT}" not in written + assert "run.sh" in written + # args token must use TOML format, not the intermediate $ARGUMENTS + assert "$ARGUMENTS" not in written + assert "{{args}}" in written + + def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir): + """Markdown agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: markdown wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-md-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-md-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "{SCRIPT}" not in written + assert "run.sh" in written + assert "strategy" not in written + + def test_register_commands_markdown_converts_args_after_script_resolution(self, project_dir): + """Markdown agents re-run arg placeholder conversion after resolve_skill_placeholders. + + resolve_skill_placeholders injects $ARGUMENTS (via {ARGS} expansion). A second + _convert_argument_placeholder call must convert those to the agent's native format. + """ + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: forge wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".forge" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-forge-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "{{parameters}}", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-forge-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{SCRIPT}" not in written + assert "run.sh" in written + # $ARGUMENTS injected by resolve_skill_placeholders must be re-converted + assert "$ARGUMENTS" not in written + assert "{{parameters}}" in written + + def test_extension_command_resolves_via_extension_directory(self, project_dir): + """Extension commands (e.g. speckit.git.feature) resolve from the extension directory. + + Both _register_skills and register_commands pass the full cmd_name to + _substitute_core_template, which tries the full name first via PresetResolver + and finds speckit.git.feature.md in the extension commands directory. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + # Place the template where a real extension would install it + ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands" + ext_cmd_dir.mkdir(parents=True, exist_ok=True) + (ext_cmd_dir / "speckit.git.feature.md").write_text( + "---\ndescription: git feature core\n---\n\n# Git Feature Core\n" + ) + # Ensure a hyphenated or dot-separated fallback does NOT exist + assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists() + assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists() + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + + # Both call sites now pass the full cmd_name + result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar) + + assert "# Git Feature Core" in result + assert "{CORE_TEMPLATE}" not in result + + def test_extension_command_resolves_via_manifest_when_filename_differs(self, project_dir): + """Extension commands whose filename differs from the command name resolve via extension.yml. + + The selftest extension maps speckit.selftest.extension → commands/selftest.md. + Name-based lookup would look for commands/speckit.selftest.extension.md and fail; + manifest-based lookup must find the actual file declared in the manifest. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + ext_dir = project_dir / ".specify" / "extensions" / "selftest" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + + # File is named selftest.md, NOT speckit.selftest.extension.md + (cmd_dir / "selftest.md").write_text( + "---\ndescription: selftest core\n---\n\n# Selftest Core\n" + ) + # Manifest maps the command name to the actual file + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " commands:\n" + " - name: speckit.selftest.extension\n" + " file: commands/selftest.md\n" + " description: Selftest command\n" + ) + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + result, _ = _substitute_core_template(body, "speckit.selftest.extension", project_dir, registrar) + + assert "# Selftest Core" in result + assert "{CORE_TEMPLATE}" not in result + + +# ===== _replay_wraps_for_command Tests ===== + +def _make_wrap_preset_dir( + base: Path, + preset_id: str, + cmd_name: str, + pre: str, + post: str, + aliases: list[str] | None = None, + file_rel: str | None = None, +) -> Path: + """Create a minimal wrap-strategy preset directory for testing.""" + preset_dir = base / preset_id + cmd_dir = preset_dir / "commands" + cmd_dir.mkdir(parents=True) + file_rel = file_rel or f"commands/{cmd_name}.md" + template = { + "type": "command", + "name": cmd_name, + "file": file_rel, + "description": f"{preset_id} wrap", + } + if aliases is not None: + template["aliases"] = aliases + manifest = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": "1.0.0", + "description": f"Preset {preset_id}", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [template] + }, + "tags": [], + } + import yaml as _yaml + (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) + command_path = preset_dir / file_rel + command_path.parent.mkdir(parents=True, exist_ok=True) + command_path.write_text( + f"---\ndescription: {preset_id} wrap\nstrategy: wrap\n---\n\n" + f"[{pre}]\n\n{{CORE_TEMPLATE}}\n\n[{post}]\n" + ) + return preset_dir + + + +class TestCompositionStrategyValidation: + """Test strategy field validation in PresetManifest.""" + + def test_valid_replace_strategy(self, temp_dir, valid_pack_data): + """Test that replace strategy is accepted.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "replace" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "replace" + + def test_valid_prepend_strategy(self, temp_dir, valid_pack_data): + """Test that prepend strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "prepend" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "prepend" + + def test_valid_append_strategy(self, temp_dir, valid_pack_data): + """Test that append strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "append" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "append" + + def test_valid_wrap_strategy(self, temp_dir, valid_pack_data): + """Test that wrap strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "wrap" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "wrap" + + def test_default_strategy_is_replace(self, pack_dir): + """Test that omitting strategy defaults to replace (key is absent).""" + manifest = PresetManifest(pack_dir / "preset.yml") + # Strategy key should not be present in the manifest data + assert "strategy" not in manifest.templates[0] + # But consumers should treat missing strategy as "replace" + assert manifest.templates[0].get("strategy", "replace") == "replace" + + def test_invalid_strategy_rejected(self, temp_dir, valid_pack_data): + """Test that invalid strategy values are rejected.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "merge" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy"): + PresetManifest(manifest_path) + + def test_prepend_rejected_for_scripts(self, temp_dir, valid_pack_data): + """Test that prepend strategy is rejected for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "prepend", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): + PresetManifest(manifest_path) + + def test_append_rejected_for_scripts(self, temp_dir, valid_pack_data): + """Test that append strategy is rejected for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "append", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): + PresetManifest(manifest_path) + + def test_wrap_accepted_for_scripts(self, temp_dir, valid_pack_data): + """Test that wrap strategy is accepted for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "wrap", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "wrap" + + def test_replace_accepted_for_scripts(self, temp_dir, valid_pack_data): + """Test that replace strategy is accepted for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "replace", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "replace" + + def test_prepend_accepted_for_commands(self, temp_dir, valid_pack_data): + """Test that prepend strategy is accepted for commands.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + "strategy": "prepend", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "prepend" + + +class TestResolveContent: + """Test PresetResolver.resolve_content() composition.""" + + def test_resolve_content_core_template(self, project_dir): + """Test resolve_content returns core template when no composition.""" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Core Spec Template" in content + + def test_resolve_content_nonexistent(self, project_dir): + """Test resolve_content returns None for nonexistent template.""" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("nonexistent") + assert content is None + + def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with default replace strategy.""" + manager = PresetManager(project_dir) + manager.install_from_directory( + _create_pack(temp_dir, valid_pack_data, "replace-pack", + "# Replaced Content\n"), + "0.1.5" + ) + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Replaced Content" in content + assert "Core Spec Template" not in content + + def test_resolve_content_append_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with append strategy.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Core Spec Template" in content + assert "Appended Section" in content + # Core should come first, appended after + assert content.index("Core Spec Template") < content.index("Appended Section") + + def test_resolve_content_prepend_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with prepend strategy.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "prepend-pack", "name": "Prepend"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] + } + pack_dir = temp_dir / "prepend-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Security Header\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Security Header" in content + assert "Core Spec Template" in content + # Prepended content should come first + assert content.index("Security Header") < content.index("Core Spec Template") + + def test_resolve_content_wrap_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for templates.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-pack", "name": "Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] + } + pack_dir = temp_dir / "wrap-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text( + "# Wrapper Start\n\n{CORE_TEMPLATE}\n\n# Wrapper End\n" + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Wrapper Start" in content + assert "Core Spec Template" in content + assert "Wrapper End" in content + # Wrapper should surround core + assert content.index("Wrapper Start") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Wrapper End") + + def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT.""" + # Create core script + scripts_dir = project_dir / ".specify" / "templates" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + (scripts_dir / "test-script.sh").write_text("echo 'core script'\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "script", + "name": "test-script", + "file": "scripts/test-script.sh", + "strategy": "wrap", + }] + } + pack_dir = temp_dir / "script-wrap" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "scripts").mkdir() + (pack_dir / "scripts" / "test-script.sh").write_text( + "#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n" + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("test-script", "script") + assert content is not None + assert "echo 'before'" in content + assert "echo 'core script'" in content + assert "echo 'after'" in content + + def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data): + """Test multi-preset composition chain: prepend + append stacking.""" + # Create preset A (priority 1): prepend security header + pack_a_data = {**valid_pack_data} + pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"} + pack_a_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] + } + pack_a_dir = temp_dir / "preset-a" + pack_a_dir.mkdir() + with open(pack_a_dir / "preset.yml", 'w') as f: + yaml.dump(pack_a_data, f) + (pack_a_dir / "templates").mkdir() + (pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n") + + # Create preset B (priority 2): append compliance footer + pack_b_data = {**valid_pack_data} + pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"} + pack_b_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_b_dir = temp_dir / "preset-b" + pack_b_dir.mkdir() + with open(pack_b_dir / "preset.yml", 'w') as f: + yaml.dump(pack_b_data, f) + (pack_b_dir / "templates").mkdir() + (pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_a_dir, "0.1.5", priority=1) + manager.install_from_directory(pack_b_dir, "0.1.5", priority=2) + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + # Result: + + + assert "Security Header" in content + assert "Core Spec Template" in content + assert "Compliance Footer" in content + assert content.index("Security Header") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Compliance Footer") + + def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data): + """Test that project overrides trump composition (replace at top priority).""" + # Install a composing preset + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + # Create project override (replaces everything) + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("# Override Only\n") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Override Only" in content + # Override replaces, so appended content should not be visible + assert "Core Spec Template" not in content + + def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with command template type.""" + # Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md) + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "plan.md").write_text("# Core Plan Command\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.plan", + "file": "commands/speckit.plan.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "cmd-append" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.plan", "command") + assert content is not None + assert "Core Plan Command" in content + assert "Additional Instructions" in content + + def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data): + """Test that command composition strips frontmatter from lower layers + and reattaches only the highest-priority frontmatter.""" + # Create core command with frontmatter + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "check.md").write_text( + "---\ndescription: Core check command\n---\nCore body content\n" + ) + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.check", + "file": "commands/speckit.check.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "fm-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.check.md").write_text( + "---\ndescription: Preset check override\n---\nPreset body content\n" + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.check", "command") + assert content is not None + # Should have the preset (highest-priority) frontmatter + assert "Preset check override" in content + # Should have both bodies + assert "Core body content" in content + assert "Preset body content" in content + # Core frontmatter should NOT appear in the body + assert content.count("---") == 2 # only one frontmatter block (opening + closing) + + def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data): + """Test that prepend/append use blank line separator.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "sep-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("appended") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + # Should have blank line separator + assert "\n\n" in content + + def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data): + """Top-priority replace layer should win even if a lower layer uses wrap.""" + # Install a low-priority wrap preset (with no placeholder — would fail if evaluated) + wrap_data = {**valid_pack_data} + wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"} + wrap_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] + } + wrap_dir = temp_dir / "wrap-lo" + wrap_dir.mkdir() + with open(wrap_dir / "preset.yml", "w") as f: + yaml.dump(wrap_data, f) + (wrap_dir / "templates").mkdir() + # Intentionally missing {CORE_TEMPLATE} — would error if composition ran + (wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder") + + manager = PresetManager(project_dir) + manager.install_from_directory(wrap_dir, "0.1.5", priority=10) + + # Install a high-priority replace preset + rep_data = {**valid_pack_data} + rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"} + rep_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + }] + } + rep_dir = temp_dir / "rep-hi" + rep_dir.mkdir() + with open(rep_dir / "preset.yml", "w") as f: + yaml.dump(rep_data, f) + (rep_dir / "templates").mkdir() + (rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n") + + manager.install_from_directory(rep_dir, "0.1.5", priority=1) + + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content == "# Replaced content\n" + + +class TestCollectAllLayers: + """Test PresetResolver.collect_all_layers() method.""" + + def test_single_core_layer(self, project_dir): + """Test collecting layers with only core template.""" + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 1 + assert layers[0]["source"] == "core" + assert layers[0]["strategy"] == "replace" + + def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): + """Test that layers include installed preset.""" + manager = PresetManager(project_dir) + pack_dir = _create_pack(temp_dir, valid_pack_data, "test-pack", + "# From Pack\n") + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 2 + # Highest priority first + assert "test-pack" in layers[0]["source"] + assert layers[1]["source"] == "core" + + def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data): + """Test that layers are ordered by priority (highest first).""" + manager = PresetManager(project_dir) + for pid, prio in [("pack-lo", 10), ("pack-hi", 1)]: + d = {**valid_pack_data} + d["preset"] = {**valid_pack_data["preset"], "id": pid, "name": pid} + p = temp_dir / pid + p.mkdir() + with open(p / "preset.yml", 'w') as f: + yaml.dump(d, f) + (p / "templates").mkdir() + (p / "templates" / "spec-template.md").write_text(f"# {pid}\n") + manager.install_from_directory(p, "0.1.5", priority=prio) + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 3 # pack-hi, pack-lo, core + assert "pack-hi" in layers[0]["source"] + assert "pack-lo" in layers[1]["source"] + assert layers[2]["source"] == "core" + + def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data): + """Test that layers read strategy from preset manifest.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "strat-pack", "name": "Strat"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "strat-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Footer\n") + + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") + + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + # Preset layer should have strategy=append + assert layers[0]["strategy"] == "append" + # Core layer should be replace + assert layers[1]["strategy"] == "replace" + + +class TestRemoveReconciliation: + """Test that removing a preset re-registers the next layer's command.""" + + def test_remove_restores_lower_priority_command( + self, project_dir, temp_dir, valid_pack_data + ): + """After removing the top-priority preset, the next preset's command + should be re-registered in agent directories.""" + manager = PresetManager(project_dir) + + # Create a gemini commands dir so reconciliation writes there + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) + + # Install a low-priority preset with a command + lo_data = {**valid_pack_data} + lo_data["preset"] = { + **valid_pack_data["preset"], + "id": "lo-preset", + "name": "Lo", + } + lo_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + lo_dir = temp_dir / "lo-preset" + lo_dir.mkdir() + with open(lo_dir / "preset.yml", "w") as f: + yaml.dump(lo_data, f) + (lo_dir / "commands").mkdir() + (lo_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: lo\n---\nLo content\n" + ) + manager.install_from_directory(lo_dir, "0.1.5", priority=10) + + # Install a high-priority preset overriding the same command + hi_data = {**valid_pack_data} + hi_data["preset"] = { + **valid_pack_data["preset"], + "id": "hi-preset", + "name": "Hi", + } + hi_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + hi_dir = temp_dir / "hi-preset" + hi_dir.mkdir() + with open(hi_dir / "preset.yml", "w") as f: + yaml.dump(hi_data, f) + (hi_dir / "commands").mkdir() + (hi_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: hi\n---\nHi content\n" + ) + manager.install_from_directory(hi_dir, "0.1.5", priority=1) + + # Verify the hi-preset's content is active in agent dir + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should exist in gemini dir" + assert "Hi content" in cmd_files[0].read_text() + + # Remove the high-priority preset + manager.remove("hi-preset") + + # The low-priority preset's command should now be in the resolution stack + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.specify", "command") + assert len(layers) >= 1 + assert "lo-preset" in layers[0]["source"] + + # Verify on-disk agent command file switched to lo-preset content + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should still exist after removal" + assert "Lo content" in cmd_files[0].read_text() + + +def _create_pack(temp_dir, valid_pack_data, pack_id, content, + strategy="replace", template_type="template", + template_name="spec-template"): + """Helper to create a preset pack directory.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": pack_id, "name": pack_id} + + tmpl_entry = { + "type": template_type, + "name": template_name, + } + if template_type == "script": + tmpl_entry["file"] = f"scripts/{template_name}.sh" + elif template_type == "command": + tmpl_entry["file"] = f"commands/{template_name}.md" + else: + tmpl_entry["file"] = f"templates/{template_name}.md" + if strategy != "replace": + tmpl_entry["strategy"] = strategy + pack_data["provides"] = {"templates": [tmpl_entry]} + + pack_dir = temp_dir / pack_id + pack_dir.mkdir(exist_ok=True) + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + + if template_type == "script": + subdir = pack_dir / "scripts" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.sh").write_text(content) + elif template_type == "command": + subdir = pack_dir / "commands" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + else: + subdir = pack_dir / "templates" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + + return pack_dir diff --git a/tests/test_registrar_path_traversal.py b/tests/test_registrar_path_traversal.py new file mode 100644 index 0000000000..fc423b4056 --- /dev/null +++ b/tests/test_registrar_path_traversal.py @@ -0,0 +1,204 @@ +"""Tests for CommandRegistrar directory traversal guards around issue #2229.""" + +import errno +from pathlib import Path + +import pytest + +from specify_cli.agents import CommandRegistrar + + +TRAVERSAL_PAYLOADS = [ + "../pwned", + "../../etc/passwd", + "subdir/../../escape", + "/absolute/evil", +] + + +def _write_source(ext_dir: Path) -> Path: + ext_dir.mkdir(parents=True, exist_ok=True) + (ext_dir / "commands").mkdir(exist_ok=True) + (ext_dir / "commands" / "cmd.md").write_text( + "---\ndescription: test\n---\n\nbody\n", encoding="utf-8" + ) + return ext_dir + + +def _cmd(name: str, aliases: list[str] | None = None) -> dict[str, object]: + return { + "name": name, + "file": "commands/cmd.md", + "aliases": list(aliases or []), + } + + +def _project_and_source(tmp_path): + project = tmp_path / "project" + project.mkdir() + ext_dir = _write_source(tmp_path / "ext-src") + return project, ext_dir + + +def _assert_no_stray_files(tmp_root: Path, marker: str) -> None: + """Fail if a file matching ``marker`` exists outside the project tree.""" + stray = [ + p for p in tmp_root.rglob("*") + if p.is_file() and marker in p.name and "project" not in p.parts + ] + assert stray == [], ( + f"Traversal payload leaked files outside the project tree: {stray}" + ) + + +class TestPrimaryCommandTraversal: + """Primary command names must not escape the agent's commands directory.""" + + @pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS) + def test_gemini_rejects_traversal_in_primary_name(self, tmp_path, bad_name): + project, ext_dir = _project_and_source(tmp_path) + (project / ".gemini" / "commands").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "gemini", [_cmd(bad_name)], "myext", ext_dir, project + ) + + _assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", "")) + + @pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS) + def test_copilot_rejects_traversal_in_primary_name(self, tmp_path, bad_name): + project, ext_dir = _project_and_source(tmp_path) + (project / ".github" / "agents").mkdir(parents=True) + (project / ".github" / "prompts").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "copilot", [_cmd(bad_name)], "myext", ext_dir, project + ) + + _assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", "")) + + +class TestAliasTraversal: + """Free-form aliases must not escape commands_dir (regression for b67b285).""" + + @pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS) + def test_gemini_rejects_traversal_in_alias(self, tmp_path, bad_alias): + project, ext_dir = _project_and_source(tmp_path) + (project / ".gemini" / "commands").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "gemini", + [_cmd("speckit.myext.ok", [bad_alias])], + "myext", + ext_dir, + project, + ) + + _assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", "")) + + @pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS) + def test_copilot_rejects_traversal_in_alias(self, tmp_path, bad_alias): + project, ext_dir = _project_and_source(tmp_path) + (project / ".github" / "agents").mkdir(parents=True) + (project / ".github" / "prompts").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "copilot", + [_cmd("speckit.myext.ok", [bad_alias])], + "myext", + ext_dir, + project, + ) + + _assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", "")) + + +class TestCopilotPromptTraversal: + """`write_copilot_prompt` is a public static method — guard it directly.""" + + @pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS) + def test_rejects_traversal_names(self, tmp_path, bad_name): + project = tmp_path / "project" + (project / ".github" / "prompts").mkdir(parents=True) + + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + CommandRegistrar.write_copilot_prompt(project, bad_name) + + _assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", "")) + + +class TestSafeRegistration: + """Positive regression — well-formed names continue to register.""" + + def test_symlinked_subdir_under_commands_dir_is_preserved(self, tmp_path): + """Lexical check must not block legitimately symlinked sub-directories. + + Teams sometimes symlink shared skills into their agent commands dir + (e.g. ``.gemini/commands/shared -> /team/shared-commands``). The + guard is purely lexical, so such a setup continues to work even though + the resolved target lives outside commands_dir on disk. + """ + project, ext_dir = _project_and_source(tmp_path) + commands_dir = project / ".gemini" / "commands" + commands_dir.mkdir(parents=True) + + external_shared = tmp_path / "external-shared" + external_shared.mkdir() + try: + (commands_dir / "shared").symlink_to( + external_shared, target_is_directory=True + ) + except OSError as exc: + if exc.errno in {errno.EPERM, errno.EACCES}: + pytest.skip("symlink creation is not permitted in this environment") + raise + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "gemini", + [_cmd("shared/hello")], + "myext", + ext_dir, + project, + ) + + assert registered == ["shared/hello"] + assert (external_shared / "hello.toml").exists() + + def test_safe_command_and_alias_still_register(self, tmp_path): + project, ext_dir = _project_and_source(tmp_path) + (project / ".claude" / "skills").mkdir(parents=True) + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "claude", + [_cmd("speckit.myext.hello", ["speckit.myext.hi"])], + "myext", + ext_dir, + project, + ) + + assert "speckit.myext.hello" in registered + assert "speckit.myext.hi" in registered + assert ( + project + / ".claude" + / "skills" + / "speckit-myext-hello" + / "SKILL.md" + ).exists() + assert ( + project + / ".claude" + / "skills" + / "speckit-myext-hi" + / "SKILL.md" + ).exists() diff --git a/tests/test_setup_plan_feature_json.py b/tests/test_setup_plan_feature_json.py new file mode 100644 index 0000000000..0203b36705 --- /dev/null +++ b/tests/test_setup_plan_feature_json.py @@ -0,0 +1,202 @@ +"""Tests for setup-plan bypassing branch-pattern checks when feature.json is valid.""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +SETUP_PLAN_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-plan.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +SETUP_PLAN_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-plan.ps1" +PLAN_TEMPLATE = PROJECT_ROOT / "templates" / "plan-template.md" + +HAS_PWSH = shutil.which("pwsh") is not None +_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") + + +def _install_bash_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "bash" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_SH, d / "common.sh") + shutil.copy(SETUP_PLAN_SH, d / "setup-plan.sh") + + +def _install_ps_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "powershell" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_PS, d / "common.ps1") + shutil.copy(SETUP_PLAN_PS, d / "setup-plan.ps1") + + +def _minimal_templates(repo: Path) -> None: + tdir = repo / ".specify" / "templates" + tdir.mkdir(parents=True, exist_ok=True) + shutil.copy(PLAN_TEMPLATE, tdir / "plan-template.md") + + +def _clean_env() -> dict[str, str]: + """Return a copy of the current environment with any SPECIFY_* vars removed. + + setup-plan.{sh,ps1} honors SPECIFY_FEATURE, SPECIFY_FEATURE_DIRECTORY, etc., + which would otherwise leak from a developer shell or CI runner and make these + tests flaky. Stripping them forces every case to rely purely on git branch + + .specify/feature.json state set up by the fixture. + """ + env = os.environ.copy() + for key in list(env): + if key.startswith("SPECIFY_"): + env.pop(key) + return env + + +def _git_init(repo: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=repo, check=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True + ) + + +@pytest.fixture +def plan_repo(tmp_path: Path) -> Path: + repo = tmp_path / "proj" + repo.mkdir() + _git_init(repo) + (repo / ".specify").mkdir() + _minimal_templates(repo) + _install_bash_scripts(repo) + _install_ps_scripts(repo) + return repo + + +@requires_bash +def test_setup_plan_passes_custom_branch_when_feature_json_valid(plan_repo: Path) -> None: + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/my-feature-branch"], + cwd=plan_repo, + check=True, + ) + feat = plan_repo / "specs" / "001-tiny-notes-app" + feat.mkdir(parents=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (plan_repo / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/001-tiny-notes-app"}), + encoding="utf-8", + ) + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script)], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + result.stdout + assert (feat / "plan.md").is_file() + + +@requires_bash +def test_setup_plan_fails_custom_branch_without_feature_json(plan_repo: Path) -> None: + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/my-feature-branch"], + cwd=plan_repo, + check=True, + ) + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script)], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + + +@requires_bash +def test_setup_plan_numbered_branch_unchanged_without_feature_json( + plan_repo: Path, +) -> None: + subprocess.run( + ["git", "checkout", "-q", "-b", "001-tiny-notes-app"], + cwd=plan_repo, + check=True, + ) + feat = plan_repo / "specs" / "001-tiny-notes-app" + feat.mkdir(parents=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + script = plan_repo / ".specify" / "scripts" / "bash" / "setup-plan.sh" + result = subprocess.run( + ["bash", str(script)], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + result.stdout + assert (feat / "plan.md").is_file() + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_plan_ps_passes_custom_branch_when_feature_json_valid(plan_repo: Path) -> None: + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/my-feature-branch"], + cwd=plan_repo, + check=True, + ) + feat = plan_repo / "specs" / "001-tiny-notes-app" + feat.mkdir(parents=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (plan_repo / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/001-tiny-notes-app"}), + encoding="utf-8", + ) + script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script)], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + result.stdout + assert (feat / "plan.md").is_file() + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_plan_ps_fails_custom_branch_without_feature_json( + plan_repo: Path, +) -> None: + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/my-feature-branch"], + cwd=plan_repo, + check=True, + ) + script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script)], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py new file mode 100644 index 0000000000..f2e10d8b0f --- /dev/null +++ b/tests/test_setup_tasks.py @@ -0,0 +1,584 @@ +"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation.""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1" +TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md" + +HAS_PWSH = shutil.which("pwsh") is not None +_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _install_bash_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "bash" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_SH, d / "common.sh") + shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh") + + +def _install_ps_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "powershell" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_PS, d / "common.ps1") + shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1") + + +def _install_core_tasks_template(repo: Path) -> None: + """Copy the real tasks-template.md into the core template location.""" + tdir = repo / ".specify" / "templates" + tdir.mkdir(parents=True, exist_ok=True) + shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md") + + +def _minimal_feature(repo: Path) -> Path: + """ + Create a numbered branch-style feature directory with spec.md and plan.md + so all prerequisite checks in setup-tasks pass. + Returns the feature directory path. + """ + feat = repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + return feat + + +def _clean_env() -> dict[str, str]: + """ + Return os.environ with all SPECIFY_* variables stripped so the scripts + rely purely on git branch + feature.json state set up by each fixture. + """ + env = os.environ.copy() + for key in list(env): + if key.startswith("SPECIFY_"): + env.pop(key) + return env + + +def _git_init(repo: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=repo, check=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True + ) + + +# --------------------------------------------------------------------------- +# Shared fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def tasks_repo(tmp_path: Path) -> Path: + """ + A minimal repo with: + - git initialised on a numbered branch (001-my-feature) + - core tasks-template.md in place + - both bash and PowerShell scripts installed + """ + repo = tmp_path / "proj" + repo.mkdir() + _git_init(repo) + + # Switch to a numbered branch so branch validation passes without feature.json + subprocess.run( + ["git", "checkout", "-q", "-b", "001-my-feature"], + cwd=repo, + check=True, + ) + + (repo / ".specify").mkdir() + _install_core_tasks_template(repo) + _install_bash_scripts(repo) + _install_ps_scripts(repo) + return repo + + +# =========================================================================== +# BASH TESTS +# =========================================================================== + +@requires_bash +def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None: + """ + When the core tasks-template.md is present and all prerequisites are met, + setup-tasks.sh --json should exit 0 and return an absolute, existing + TASKS_TEMPLATE path pointing to the core template. + """ + feat = _minimal_feature(tasks_repo) + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl.name == "tasks-template.md" + + +@requires_bash +def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None: + """ + When an override exists at .specify/templates/overrides/tasks-template.md, + setup-tasks.sh --json must return the override path, not the core path. + """ + feat = _minimal_feature(tasks_repo) + + # Create the override + overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + override_file = overrides_dir / "tasks-template.md" + override_file.write_text("# override tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + # The resolved path must be inside the overrides directory + assert "overrides" in tasks_tmpl.parts, ( + f"Expected override path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None: + """ + When an extension template exists, setup-tasks.sh --json must resolve + tasks-template.md from the extension before falling back to the core path. + """ + feat = _minimal_feature(tasks_repo) + + # FIX: real extension layout is .specify/extensions//templates/.md + extension_dir = ( + tasks_repo / ".specify" / "extensions" / "test-extension" / "templates" + ) + extension_dir.mkdir(parents=True, exist_ok=True) + extension_file = extension_dir / "tasks-template.md" + extension_file.write_text("# extension tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl == extension_file.resolve(), ( + f"Expected extension path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None: + """ + When both preset and extension templates exist, setup-tasks.sh --json must + resolve the preset path because presets outrank extensions. + """ + feat = _minimal_feature(tasks_repo) + + # FIX: real extension layout is .specify/extensions//templates/.md + extension_dir = ( + tasks_repo / ".specify" / "extensions" / "test-extension" / "templates" + ) + extension_dir.mkdir(parents=True, exist_ok=True) + extension_file = extension_dir / "tasks-template.md" + extension_file.write_text("# extension tasks template\n", encoding="utf-8") + + # FIX: real preset layout is .specify/presets//templates/.md + preset_dir = tasks_repo / ".specify" / "presets" / "test-preset" / "templates" + preset_dir.mkdir(parents=True, exist_ok=True) + preset_file = preset_dir / "tasks-template.md" + preset_file.write_text("# preset tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl == preset_file.resolve(), ( + f"Expected preset path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None: + """ + When two presets both provide tasks-template.md, the one listed first in + .specify/presets/.registry wins. + """ + feat = _minimal_feature(tasks_repo) + + # resolve_template reads .specify/presets/.registry as a JSON object with a + # "presets" map where each entry has a numeric "priority" (lower = higher + # precedence). Create two presets; priority-1-preset wins over priority-2-preset. + high_priority_dir = ( + tasks_repo / ".specify" / "presets" / "priority-1-preset" / "templates" + ) + high_priority_dir.mkdir(parents=True, exist_ok=True) + high_priority_file = high_priority_dir / "tasks-template.md" + high_priority_file.write_text("# high priority preset tasks template\n", encoding="utf-8") + low_priority_dir = ( + tasks_repo / ".specify" / "presets" / "priority-2-preset" / "templates" + ) + + low_priority_dir.mkdir(parents=True, exist_ok=True) + low_priority_file = low_priority_dir / "tasks-template.md" + low_priority_file.write_text("# low priority preset tasks template\n", encoding="utf-8") + + # Write .registry JSON using the correct schema: object with "presets" map, + # each preset has a numeric "priority" (lower number = higher precedence). + registry_json = tasks_repo / ".specify" / "presets" / ".registry" + registry_json.write_text( + json.dumps({ + "presets": { + "priority-1-preset": {"priority": 1, "enabled": True}, + "priority-2-preset": {"priority": 2, "enabled": True}, + } + }), + encoding="utf-8", + ) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl == high_priority_file.resolve(), ( + f"Expected high-priority preset path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None: + """ + When tasks-template.md is absent from all locations, setup-tasks.sh must + exit non-zero and print a helpful ERROR message to stderr. + """ + feat = _minimal_feature(tasks_repo) + + # Remove the core template so no template exists anywhere + core = tasks_repo / ".specify" / "templates" / "tasks-template.md" + core.unlink() + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "ERROR" in result.stderr + assert "tasks-template" in result.stderr + + +@requires_bash +def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch, setup-tasks.sh must succeed when feature.json + pins a valid FEATURE_DIR (branch validation should be skipped). + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + feat = tasks_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + + (tasks_repo / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/001-my-feature"}), + encoding="utf-8", + ) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + +@requires_bash +def test_setup_tasks_bash_fails_custom_branch_without_feature_json( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch with no feature.json, setup-tasks.sh must fail + and report that we are not on a feature branch. + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + + +# =========================================================================== +# POWERSHELL TESTS +# =========================================================================== + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None: + """ + When the core tasks-template.md is present and all prerequisites are met, + setup-tasks.ps1 -Json should exit 0 and return an absolute, existing + TASKS_TEMPLATE path. + """ + feat = _minimal_feature(tasks_repo) + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl.name == "tasks-template.md" + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None: + """ + When an override exists at .specify/templates/overrides/tasks-template.md, + setup-tasks.ps1 -Json must return the override path, not the core path. + """ + feat = _minimal_feature(tasks_repo) + + overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + override_file = overrides_dir / "tasks-template.md" + override_file.write_text("# override tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert "overrides" in tasks_tmpl.parts, ( + f"Expected override path but got: {tasks_tmpl}" + ) + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None: + """ + When tasks-template.md is absent from all locations, setup-tasks.ps1 must + exit non-zero and write a helpful error to stderr. + """ + feat = _minimal_feature(tasks_repo) + + core = tasks_repo / ".specify" / "templates" / "tasks-template.md" + core.unlink() + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower() + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch, setup-tasks.ps1 must succeed when feature.json + pins a valid FEATURE_DIR (branch validation should be skipped). + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + feat = tasks_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + + (tasks_repo / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/001-my-feature"}), + encoding="utf-8", + ) + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_fails_custom_branch_without_feature_json( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch with no feature.json, setup-tasks.ps1 must fail + and report that we are not on a feature branch. + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + \ No newline at end of file diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 2c13853119..c99f675081 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -4,6 +4,7 @@ Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`. """ +import json import os import re import shutil @@ -12,10 +13,27 @@ import pytest +from tests.conftest import requires_bash + PROJECT_ROOT = Path(__file__).resolve().parent.parent CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1" +EXT_CREATE_FEATURE = ( + PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" +) +EXT_CREATE_FEATURE_PS = ( + PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" +) COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" +EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" + +HAS_PWSH = shutil.which("pwsh") is not None + + +def _has_pwsh() -> bool: + """Check if pwsh is available.""" + return HAS_PWSH @pytest.fixture @@ -41,6 +59,62 @@ def git_repo(tmp_path: Path) -> Path: return tmp_path +@pytest.fixture +def ext_git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with extension scripts (for GIT_BRANCH_NAME tests).""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True) + # Extension script needs common.sh at .specify/scripts/bash/ + specify_scripts = tmp_path / ".specify" / "scripts" / "bash" + specify_scripts.mkdir(parents=True) + shutil.copy(COMMON_SH, specify_scripts / "common.sh") + # Also install core scripts for compatibility + core_scripts = tmp_path / "scripts" / "bash" + core_scripts.mkdir(parents=True) + shutil.copy(COMMON_SH, core_scripts / "common.sh") + # Copy extension script + ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash" + ext_dir.mkdir(parents=True) + shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh") + # Also copy git-common.sh if it exists + git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + if git_common.exists(): + shutil.copy(git_common, ext_dir / "git-common.sh") + (tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True) + (tmp_path / "specs").mkdir(exist_ok=True) + return tmp_path + + +@pytest.fixture +def ext_ps_git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with PowerShell extension scripts.""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True) + # Install core PS scripts + ps_dir = tmp_path / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + shutil.copy(common_ps, ps_dir / "common.ps1") + # Also install at .specify/scripts/powershell/ for extension resolution + specify_ps = tmp_path / ".specify" / "scripts" / "powershell" + specify_ps.mkdir(parents=True) + shutil.copy(common_ps, specify_ps / "common.ps1") + # Copy extension script + ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell" + ext_ps.mkdir(parents=True) + shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1") + git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + if git_common_ps.exists(): + shutil.copy(git_common_ps, ext_ps / "git-common.ps1") + (tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True) + (tmp_path / "specs").mkdir(exist_ok=True) + return tmp_path + + @pytest.fixture def no_git_dir(tmp_path: Path) -> Path: """Create a temp directory without git, but with scripts.""" @@ -77,6 +151,7 @@ def source_and_call(func_call: str, env: dict | None = None) -> subprocess.Compl # ── Timestamp Branch Tests ─────────────────────────────────────────────────── +@requires_bash class TestTimestampBranch: def test_timestamp_creates_branch(self, git_repo: Path): """Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix.""" @@ -122,6 +197,7 @@ def test_long_name_truncation(self, git_repo: Path): # ── Sequential Branch Tests ────────────────────────────────────────────────── +@requires_bash class TestSequentialBranch: def test_sequential_default_with_existing_specs(self, git_repo: Path): """Test 2: Sequential default with existing specs.""" @@ -160,6 +236,8 @@ def test_sequential_supports_four_digit_prefixes(self, git_repo: Path): branch = line.split(":", 1)[1].strip() assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}" + +class TestSequentialBranchPowerShell: def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): """PowerShell scanner should parse large prefixes without [int] casts.""" content = CREATE_FEATURE_PS.read_text(encoding="utf-8") @@ -170,6 +248,7 @@ def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): # ── check_feature_branch Tests ─────────────────────────────────────────────── +@requires_bash class TestCheckFeatureBranch: def test_accepts_timestamp_branch(self): """Test 6: check_feature_branch accepts timestamp branch.""" @@ -206,10 +285,35 @@ def test_rejects_7digit_timestamp_without_slug(self): result = source_and_call('check_feature_branch "2026031-143022" "true"') assert result.returncode != 0 + def test_accepts_single_prefix_sequential(self): + """Optional gitflow-style prefix: one segment + sequential feature name.""" + result = source_and_call('check_feature_branch "feat/004-my-feature" "true"') + assert result.returncode == 0 + + def test_accepts_single_prefix_timestamp(self): + """Optional prefix + timestamp-style feature name.""" + result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"') + assert result.returncode == 0 + + def test_rejects_invalid_suffix_with_single_prefix(self): + result = source_and_call('check_feature_branch "feat/main" "true"') + assert result.returncode != 0 + assert "feat/main" in result.stderr + + def test_rejects_two_level_prefix_before_feature(self): + """More than one slash: no stripping; whole name must match (fails).""" + result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"') + assert result.returncode != 0 + + def test_rejects_malformed_timestamp_with_prefix(self): + result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"') + assert result.returncode != 0 + # ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── +@requires_bash class TestFindFeatureDirByPrefix: def test_timestamp_branch(self, tmp_path: Path): """Test 10: find_feature_dir_by_prefix with timestamp branch.""" @@ -238,10 +342,73 @@ def test_four_digit_sequential_prefix(self, tmp_path: Path): assert result.returncode == 0 assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat" + def test_sequential_with_single_path_prefix(self, tmp_path: Path): + """Strip one optional prefix segment before prefix directory lookup.""" + (tmp_path / "specs" / "004-only-dir").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir" + + def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path): + (tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical" + + +# ── get_feature_paths + single-prefix integration ─────────────────────────── + + +class TestGetFeaturePathsSinglePrefix: + @requires_bash + def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path): + """get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup.""" + (tmp_path / ".specify").mkdir() + (tmp_path / "specs" / "001-target-spec").mkdir(parents=True) + cmd = ( + f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && ' + f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"' + ) + result = subprocess.run( + ["bash", "-c", cmd], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): + """PowerShell Get-FeaturePathsEnv: same prefix stripping as bash.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + spec_dir = git_repo / "specs" / "001-ps-prefix-spec" + spec_dir.mkdir(parents=True) + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"}, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip() + assert val == str(spec_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") + # ── get_current_branch Tests ───────────────────────────────────────────────── +@requires_bash class TestGetCurrentBranch: def test_env_var(self): """Test 12: get_current_branch returns SPECIFY_FEATURE env var.""" @@ -252,6 +419,7 @@ def test_env_var(self): # ── No-git Tests ───────────────────────────────────────────────────────────── +@requires_bash class TestNoGitTimestamp: def test_no_git_timestamp(self, no_git_dir: Path): """Test 13: No-git repo + timestamp creates spec dir with warning.""" @@ -265,6 +433,7 @@ def test_no_git_timestamp(self, no_git_dir: Path): # ── E2E Flow Tests ─────────────────────────────────────────────────────────── +@requires_bash class TestE2EFlow: def test_e2e_timestamp(self, git_repo: Path): """Test 14: E2E timestamp flow — branch, dir, validation.""" @@ -298,6 +467,7 @@ def test_e2e_sequential(self, git_repo: Path): # ── Allow Existing Branch Tests ────────────────────────────────────────────── +@requires_bash class TestAllowExistingBranch: def test_allow_existing_switches_to_branch(self, git_repo: Path): """T006: Pre-create branch, verify script switches to it.""" @@ -428,6 +598,43 @@ def test_allow_existing_no_git(self, no_git_dir: Path): ) assert result.returncode == 0, result.stderr + def test_allow_existing_surfaces_checkout_error(self, git_repo: Path): + """Checkout failures on an existing branch should include Git's stderr.""" + shared_file = git_repo / "shared.txt" + shared_file.write_text("base\n") + subprocess.run( + ["git", "add", "shared.txt"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "commit", "-m", "add shared file", "-q"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-b", "010-checkout-failure"], + cwd=git_repo, check=True, capture_output=True, + ) + shared_file.write_text("branch version\n") + subprocess.run( + ["git", "commit", "-am", "branch change", "-q"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + shared_file.write_text("uncommitted main change\n") + + result = run_script( + git_repo, "--allow-existing-branch", "--short-name", "checkout-failure", + "--number", "10", "Checkout failure", + ) + + assert result.returncode != 0, "checkout should fail with conflicting local changes" + assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr + assert "would be overwritten by checkout" in result.stderr + assert "shared.txt" in result.stderr + class TestAllowExistingBranchPowerShell: def test_powershell_supports_allow_existing_branch_flag(self): @@ -437,10 +644,31 @@ def test_powershell_supports_allow_existing_branch_flag(self): # Ensure the flag is referenced in script logic, not just declared assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "") + def test_powershell_surfaces_checkout_errors(self): + """Static guard: PS script preserves checkout stderr on existing-branch failures.""" + contents = CREATE_FEATURE_PS.read_text(encoding="utf-8") + assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents + assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents + + +class TestGitExtensionParity: + def test_bash_extension_surfaces_checkout_errors(self): + """Static guard: git extension bash script preserves checkout stderr.""" + contents = EXT_CREATE_FEATURE.read_text(encoding="utf-8") + assert 'switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1)' in contents + assert "Failed to switch to existing branch '$BRANCH_NAME'" in contents + + def test_powershell_extension_surfaces_checkout_errors(self): + """Static guard: git extension PowerShell script preserves checkout stderr.""" + contents = EXT_CREATE_FEATURE_PS.read_text(encoding="utf-8") + assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents + assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents + # ── Dry-Run Tests ──────────────────────────────────────────────────────────── +@requires_bash class TestDryRun: def test_dry_run_sequential_outputs_name(self, git_repo: Path): """T009: Dry-run computes correct branch name with existing specs.""" @@ -669,15 +897,6 @@ def test_dry_run_no_git(self, no_git_dir: Path): # ── PowerShell Dry-Run Tests ───────────────────────────────────────────────── -def _has_pwsh() -> bool: - """Check if pwsh is available.""" - try: - subprocess.run(["pwsh", "--version"], capture_output=True, check=True) - return True - except (FileNotFoundError, subprocess.CalledProcessError): - return False - - def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess: """Run create-new-feature.ps1 from the temp repo's scripts directory.""" script = cwd / "scripts" / "powershell" / "create-new-feature.ps1" @@ -774,3 +993,331 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): assert result.returncode == 0, result.stderr data = json.loads(result.stdout) assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" + + +# ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── + + +@requires_bash +class TestGitBranchNameOverrideBash: + """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" + + def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str): + script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" + cmd = ["bash", str(script), "--json", *extra_args, "ignored"] + return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True, + env={**os.environ, **env_extras}) + + def test_exact_name_no_prefix(self, ext_git_repo: Path): + """GIT_BRANCH_NAME is used verbatim with no numeric prefix added.""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "my-exact-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "my-exact-branch" + assert data["FEATURE_NUM"] == "my-exact-branch" + + def test_sequential_prefix_extraction(self, ext_git_repo: Path): + """FEATURE_NUM extracted from sequential-style prefix (digits before dash).""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "042-custom-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "042-custom-branch" + assert data["FEATURE_NUM"] == "042" + + def test_timestamp_prefix_extraction(self, ext_git_repo: Path): + """FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names.""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-my-feature"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "20260407-143022-my-feature" + assert data["FEATURE_NUM"] == "20260407-143022" + + def test_overlong_name_rejected(self, ext_git_repo: Path): + """GIT_BRANCH_NAME exceeding 244 bytes is rejected with an error.""" + long_name = "a" * 245 + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": long_name}) + assert result.returncode != 0 + assert "244" in result.stderr + + def test_dry_run_with_override(self, ext_git_repo: Path): + """GIT_BRANCH_NAME works with --dry-run (no branch created).""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "dry-run-override"}, "--dry-run") + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "dry-run-override" + assert data.get("DRY_RUN") is True + branches = subprocess.run( + ["git", "branch", "--list", "dry-run-override"], + cwd=ext_git_repo, capture_output=True, text=True, + ) + assert "dry-run-override" not in branches.stdout + + +@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") +class TestGitBranchNameOverridePowerShell: + """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1.""" + + def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict): + script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" + return subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"], + cwd=ext_ps_git_repo, capture_output=True, text=True, + env={**os.environ, **env_extras}, + ) + + def test_exact_name_no_prefix(self, ext_ps_git_repo: Path): + """GIT_BRANCH_NAME is used verbatim with no numeric prefix added.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "ps-exact-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "ps-exact-branch" + assert data["FEATURE_NUM"] == "ps-exact-branch" + + def test_sequential_prefix_extraction(self, ext_ps_git_repo: Path): + """FEATURE_NUM extracted from sequential-style prefix.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "099-ps-numbered"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "099-ps-numbered" + assert data["FEATURE_NUM"] == "099" + + def test_timestamp_prefix_extraction(self, ext_ps_git_repo: Path): + """FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-ps-feature"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "20260407-143022-ps-feature" + assert data["FEATURE_NUM"] == "20260407-143022" + + def test_overlong_name_rejected(self, ext_ps_git_repo: Path): + """GIT_BRANCH_NAME exceeding 244 bytes is rejected.""" + long_name = "a" * 245 + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": long_name}) + assert result.returncode != 0 + assert "244" in result.stderr + + +# ── Feature Directory Resolution Tests ─────────────────────────────────────── + + +class TestFeatureDirectoryResolution: + """Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution.""" + + @requires_bash + def test_env_var_overrides_branch_lookup(self, git_repo: Path): + """SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup.""" + custom_dir = git_repo / "my-custom-specs" / "my-feature" + custom_dir.mkdir(parents=True) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)}, + ) + assert result.returncode == 0, result.stderr + assert str(custom_dir) in result.stdout + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + @requires_bash + def test_feature_json_overrides_branch_lookup(self, git_repo: Path): + """feature.json feature_directory takes priority over branch-based lookup.""" + custom_dir = git_repo / "specs" / "custom-feature" + custom_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + json.dumps({"feature_directory": str(custom_dir)}) + "\n", + encoding="utf-8", + ) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + @requires_bash + def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): + """Env var wins over feature.json.""" + env_dir = git_repo / "specs" / "env-feature" + env_dir.mkdir(parents=True) + json_dir = git_repo / "specs" / "json-feature" + json_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + json.dumps({"feature_directory": str(json_dir)}) + "\n", + encoding="utf-8", + ) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(env_dir)}, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(env_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + @requires_bash + def test_fallback_to_branch_lookup(self, git_repo: Path): + """Without env var or feature.json, falls back to branch-based lookup.""" + subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True) + spec_dir = git_repo / "specs" / "001-test-feat" + spec_dir.mkdir(parents=True) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(spec_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path): + """PowerShell: SPECIFY_FEATURE_DIRECTORY env var takes priority.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + custom_dir = git_repo / "my-custom-specs" / "ps-feature" + custom_dir.mkdir(parents=True) + + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)}, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path): + """PowerShell: feature.json takes priority over branch-based lookup.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + custom_dir = git_repo / "specs" / "ps-json-feature" + custom_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + json.dumps({"feature_directory": str(custom_dir)}) + "\n", + encoding="utf-8", + ) + + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") + + +# ── Description Quoting Tests (issue #2339) ────────────────────────────────── + + +@requires_bash +class TestDescriptionQuoting: + """Descriptions with quotes, apostrophes, and backslashes must not break the script. + + Regression tests for https://github.com/github/spec-kit/issues/2339 + """ + + @pytest.mark.parametrize( + "description", + [ + "Add user's profile page", + "Fix the \"login\" bug", + "Handle path\\with\\backslashes", + "It's a \"complex\" feature\\here", + ], + ids=["apostrophe", "double-quotes", "backslashes", "mixed"], + ) + def test_core_script_handles_special_chars(self, git_repo: Path, description: str): + """Core create-new-feature.sh succeeds with special characters in description.""" + result = run_script(git_repo, "--dry-run", "--short-name", "feat", description) + assert result.returncode == 0, ( + f"Script failed for description {description!r}: {result.stderr}" + ) + + @pytest.mark.parametrize( + "description", + [ + "Add user's profile page", + "Fix the \"login\" bug", + "Handle path\\with\\backslashes", + "It's a \"complex\" feature\\here", + ], + ids=["apostrophe", "double-quotes", "backslashes", "mixed"], + ) + def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str): + """Extension create-new-feature.sh succeeds with special characters in description.""" + script = ( + ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" + ) + result = subprocess.run( + ["bash", str(script), "--dry-run", "--short-name", "feat", description], + cwd=ext_git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"Script failed for description {description!r}: {result.stderr}" + ) + + def test_whitespace_only_still_rejected(self, git_repo: Path): + """Whitespace-only descriptions must still be rejected after trimming.""" + result = run_script(git_repo, "--dry-run", "--short-name", "feat", " ") + assert result.returncode != 0 + assert "empty" in result.stderr.lower() or "whitespace" in result.stderr.lower() + + def test_plain_description_still_works(self, git_repo: Path): + """Plain description without special characters continues to work.""" + result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature") + assert result.returncode == 0, result.stderr diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py new file mode 100644 index 0000000000..7169c44df0 --- /dev/null +++ b/tests/test_upgrade.py @@ -0,0 +1,411 @@ +"""Tests for the `specify self` sub-app (`self check` and `self upgrade`). + +Network isolation contract (SC-004 / FR-014): every test that exercises +`specify self check` or `_fetch_latest_release_tag()` MUST mock +`urllib.request.urlopen` so no real outbound call ever reaches +api.github.com. The `self upgrade` stub tests do not need that patch because +the stub is contractually network-free. Run this module under `pytest-socket` +(if installed) with `--disable-socket` as an extra safety net. +""" + +import json +import urllib.error +import importlib.metadata +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from specify_cli import ( + _get_installed_version, + _fetch_latest_release_tag, + _is_newer, + _normalize_tag, + app, +) +from tests.conftest import strip_ansi + +runner = CliRunner() + +SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE" +SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" + +_RATE_LIMITED_REASON = ( + "rate limited (configure ~/.specify/auth.json with a GitHub token)" +) + + +def _mock_urlopen_response(payload: dict) -> MagicMock: + body = json.dumps(payload).encode("utf-8") + resp = MagicMock() + resp.read.return_value = body + cm = MagicMock() + cm.__enter__.return_value = resp + cm.__exit__.return_value = False + return cm + + +def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError: + return urllib.error.HTTPError( + url="https://api.github.com/repos/github/spec-kit/releases/latest", + code=code, + msg=message, + hdrs={}, # type: ignore[arg-type] + fp=None, + ) + + +class TestSelfUpgradeStub: + """Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016).""" + + def test_prints_exactly_three_lines_and_exits_zero(self): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + lines = strip_ansi(result.output).strip().splitlines() + assert lines == [ + "specify self upgrade is not implemented yet.", + "Run 'specify self check' to see whether a newer release is available.", + "Actual self-upgrade is planned as follow-up work.", + ] + + def test_stub_makes_no_network_call(self): + # The stub must not hit the network via either urllib path: + # unauthenticated requests use urlopen() directly; authenticated ones + # go through build_opener(...).open(). Both are patched so that any + # accidental network call raises immediately. + network_error = AssertionError("stub must not hit the network") + with ( + patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=network_error, + ), + patch( + "specify_cli.authentication.http.urllib.request.build_opener", + side_effect=network_error, + ), + ): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + + +class TestIsNewer: + def test_latest_strictly_greater_returns_true(self): + assert _is_newer("0.8.0", "0.7.4") is True + + def test_equal_versions_returns_false(self): + assert _is_newer("0.7.4", "0.7.4") is False + + def test_current_greater_than_latest_returns_false(self): + assert _is_newer("0.7.0", "0.7.4") is False + + def test_dev_build_ahead_of_release_returns_false(self): + assert _is_newer("0.7.4", "0.7.5.dev0") is False + + def test_invalid_version_returns_false(self): + assert _is_newer("not-a-version", "0.7.4") is False + + def test_local_version_containing_unknown_is_not_treated_as_sentinel(self): + assert _is_newer("1.2.4", "1.2.3+unknown") is True + + +class TestInstalledVersion: + def test_invalid_metadata_error_returns_unknown(self): + invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) + if invalid_metadata_error is None: + # Python versions without InvalidMetadataError: simulate with a + # custom exception to verify the guarded except path works. + class _FakeInvalidMetadataError(Exception): + pass + invalid_metadata_error = _FakeInvalidMetadataError + # Patch the attribute onto importlib.metadata so the production + # getattr() finds it during this test. + with patch.object(importlib.metadata, "InvalidMetadataError", invalid_metadata_error, create=True): + with patch( + "importlib.metadata.version", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert _get_installed_version() == "unknown" + else: + with patch( + "importlib.metadata.version", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert _get_installed_version() == "unknown" + + +class TestNormalizeTag: + def test_strips_single_leading_v(self): + assert _normalize_tag("v0.7.4") == "0.7.4" + + def test_idempotent_when_no_leading_v(self): + assert _normalize_tag("0.7.4") == "0.7.4" + + def test_strips_exactly_one_v(self): + assert _normalize_tag("vv0.7.4") == "v0.7.4" + + def test_empty_string_passthrough(self): + assert _normalize_tag("") == "" + + +class TestUserStory1: + def test_newer_available_prints_update_and_install_command(self): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" in output + assert "0.7.4" in output + assert "0.9.0" in output + assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output + + def test_up_to_date_prints_current_only(self): + with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Up to date: 0.9.0" in output + assert "Update available" not in output + assert "git+https://" not in output + + def test_dev_build_ahead_of_release_is_up_to_date(self): + with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" not in output + assert "Up to date" in output + + def test_unknown_installed_still_prints_latest_and_reinstall(self): + with patch("specify_cli._get_installed_version", return_value="unknown"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Current version could not be determined" in output + assert "0.7.4" in output + assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output + + def test_unparseable_tag_routes_to_indeterminate(self): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "not-a-version"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" not in output + assert "Up to date" in output + assert "0.7.4" in output + + +class TestFailureCategorization: + def test_urlerror_maps_to_offline(self): + with patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=urllib.error.URLError("no route to host"), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == "offline or timeout" + + def test_timeout_maps_to_offline(self): + with patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=TimeoutError(), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == "offline or timeout" + + def test_403_maps_to_rate_limited(self): + with patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=_http_error(403, "rate limited"), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == _RATE_LIMITED_REASON + + @pytest.mark.parametrize("code", [404, 500, 502]) + def test_other_http_uses_code_string(self, code): + with patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=_http_error(code, "oops"), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == f"HTTP {code}" + + def test_generic_exception_propagates(self): + # Per research D-006, no catch-all exists; RuntimeError MUST bubble. + with patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=RuntimeError("boom"), + ): + with pytest.raises(RuntimeError): + _fetch_latest_release_tag() + + +_FAILURE_CASES = [ + ("offline or timeout", urllib.error.URLError("down")), + (_RATE_LIMITED_REASON, _http_error(403)), + ("HTTP 500", _http_error(500)), +] + + +class TestUserStory2: + @pytest.mark.parametrize("expected_reason, side_effect", _FAILURE_CASES) + def test_failure_prints_installed_plus_one_line_reason( + self, expected_reason, side_effect + ): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert "Installed: 0.7.4" in output + if expected_reason == _RATE_LIMITED_REASON: + assert "Could not check latest release: rate limited" in output + assert "~/.specify/auth.json" in output + else: + assert f"Could not check latest release: {expected_reason}" in output + + @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) + def test_failure_exits_zero(self, _expected_reason, side_effect): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + assert result.exit_code == 0 + + @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) + def test_failure_output_contains_no_traceback_no_url( + self, _expected_reason, side_effect + ): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + combined = (result.output or "") + (result.stderr or "") + combined = strip_ansi(combined) + assert "Traceback" not in combined + assert "https://api.github.com" not in combined + + +def _capture_request_via_urlopen(): + captured = {} + + def _side_effect(req, timeout=None): + captured["request"] = req + return _mock_urlopen_response({"tag_name": "v0.7.4"}) + + return captured, _side_effect + + +def _inject_github_config(monkeypatch, token_env="GH_TOKEN"): + from tests.auth_helpers import inject_github_config + inject_github_config(monkeypatch, token_env) + + +class TestUserStory3: + def test_gh_token_attached_as_bearer_header(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + _inject_github_config(monkeypatch, token_env="GH_TOKEN") + captured, side_effect = _capture_request_via_urlopen() + mock_opener = MagicMock() + mock_opener.open.side_effect = side_effect + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}" + + def test_github_token_used_when_gh_token_unset(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + _inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + captured, side_effect = _capture_request_via_urlopen() + mock_opener = MagicMock() + mock_opener.open.side_effect = side_effect + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" + + def test_no_authorization_header_when_both_unset(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") is None + + def test_empty_string_gh_token_treated_as_unset(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", "") + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + _inject_github_config(monkeypatch, token_env="GH_TOKEN") + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") is None + + def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", " ") + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + _inject_github_config(monkeypatch, token_env="GH_TOKEN") + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") is None + + def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", " ") + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + _inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") + captured, side_effect = _capture_request_via_urlopen() + mock_opener = MagicMock() + mock_opener.open.side_effect = side_effect + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" + + @pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES) + def test_gh_token_never_appears_in_failure_output( + self, _reason, side_effect, monkeypatch + ): + monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + combined = strip_ansi((result.output or "") + (result.stderr or "")) + assert SENTINEL_GH_TOKEN not in combined + + @pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES) + def test_github_token_never_appears_in_failure_output( + self, _reason, side_effect, monkeypatch + ): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + combined = strip_ansi((result.output or "") + (result.stderr or "")) + assert SENTINEL_GITHUB_TOKEN not in combined diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000000..4c042fc7d5 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,1845 @@ +"""Tests for the workflow engine subsystem. + +Covers: +- Step registry & auto-discovery +- Base classes (StepBase, StepContext, StepResult) +- Expression engine +- All 10 built-in step types +- Workflow definition loading & validation +- Workflow engine execution & state persistence +- Workflow catalog & registry +""" + +from __future__ import annotations + +import json +import shutil +import tempfile +from pathlib import Path + +import pytest +import yaml + + +# --------------------------------------------------------------------------- +# 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 with .specify/ directory.""" + specify_dir = temp_dir / ".specify" + specify_dir.mkdir() + (specify_dir / "workflows").mkdir() + return temp_dir + + +@pytest.fixture +def sample_workflow_yaml(): + """Return a valid minimal workflow YAML string.""" + return """ +schema_version: "1.0" +workflow: + id: "test-workflow" + name: "Test Workflow" + version: "1.0.0" + description: "A test workflow" + +inputs: + spec: + type: string + required: true + scope: + type: string + default: "full" + +steps: + - id: step-one + command: speckit.specify + input: + args: "{{ inputs.spec }}" + + - id: step-two + command: speckit.plan + input: + args: "{{ steps.step-one.output.command }}" +""" + + +@pytest.fixture +def sample_workflow_file(project_dir, sample_workflow_yaml): + """Write a sample workflow YAML to a file and return its path.""" + wf_dir = project_dir / ".specify" / "workflows" / "test-workflow" + wf_dir.mkdir(parents=True, exist_ok=True) + wf_path = wf_dir / "workflow.yml" + wf_path.write_text(sample_workflow_yaml, encoding="utf-8") + return wf_path + + +# ===== Step Registry Tests ===== + +class TestStepRegistry: + """Test STEP_REGISTRY and auto-discovery.""" + + def test_registry_populated(self): + from specify_cli.workflows import STEP_REGISTRY + + assert len(STEP_REGISTRY) >= 10 + + def test_all_step_types_registered(self): + from specify_cli.workflows import STEP_REGISTRY + + expected = { + "command", "shell", "prompt", "gate", "if", "switch", + "while", "do-while", "fan-out", "fan-in", + } + assert expected.issubset(set(STEP_REGISTRY.keys())) + + def test_get_step_type(self): + from specify_cli.workflows import get_step_type + + step = get_step_type("command") + assert step is not None + assert step.type_key == "command" + + def test_get_step_type_missing(self): + from specify_cli.workflows import get_step_type + + assert get_step_type("nonexistent") is None + + def test_register_step_duplicate_raises(self): + from specify_cli.workflows import _register_step + from specify_cli.workflows.steps.command import CommandStep + + with pytest.raises(KeyError, match="already registered"): + _register_step(CommandStep()) + + def test_register_step_empty_key_raises(self): + from specify_cli.workflows import _register_step + from specify_cli.workflows.base import StepBase, StepResult + + class EmptyStep(StepBase): + type_key = "" + def execute(self, config, context): + return StepResult() + + with pytest.raises(ValueError, match="empty type_key"): + _register_step(EmptyStep()) + + +# ===== Base Classes Tests ===== + +class TestBaseClasses: + """Test StepBase, StepContext, StepResult.""" + + def test_step_context_defaults(self): + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert ctx.inputs == {} + assert ctx.steps == {} + assert ctx.item is None + assert ctx.fan_in == {} + assert ctx.default_integration is None + + def test_step_context_with_data(self): + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + inputs={"name": "test"}, + default_integration="claude", + default_model="sonnet-4", + ) + assert ctx.inputs == {"name": "test"} + assert ctx.default_integration == "claude" + assert ctx.default_model == "sonnet-4" + + def test_step_result_defaults(self): + from specify_cli.workflows.base import StepResult, StepStatus + + result = StepResult() + assert result.status == StepStatus.COMPLETED + assert result.output == {} + assert result.next_steps == [] + assert result.error is None + + def test_step_status_values(self): + from specify_cli.workflows.base import StepStatus + + assert StepStatus.PENDING == "pending" + assert StepStatus.RUNNING == "running" + assert StepStatus.COMPLETED == "completed" + assert StepStatus.FAILED == "failed" + assert StepStatus.SKIPPED == "skipped" + assert StepStatus.PAUSED == "paused" + + def test_run_status_values(self): + from specify_cli.workflows.base import RunStatus + + assert RunStatus.CREATED == "created" + assert RunStatus.RUNNING == "running" + assert RunStatus.PAUSED == "paused" + assert RunStatus.COMPLETED == "completed" + assert RunStatus.FAILED == "failed" + assert RunStatus.ABORTED == "aborted" + + +# ===== Expression Engine Tests ===== + +class TestExpressions: + """Test sandboxed expression evaluator.""" + + def test_simple_variable(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"name": "login"}) + assert evaluate_expression("{{ inputs.name }}", ctx) == "login" + + def test_step_output_reference(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"specify": {"output": {"file": "spec.md"}}} + ) + assert evaluate_expression("{{ steps.specify.output.file }}", ctx) == "spec.md" + + def test_string_interpolation(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"name": "login"}) + result = evaluate_expression("Feature: {{ inputs.name }} done", ctx) + assert result == "Feature: login done" + + def test_comparison_equals(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"scope": "full"}) + assert evaluate_expression("{{ inputs.scope == 'full' }}", ctx) is True + assert evaluate_expression("{{ inputs.scope == 'partial' }}", ctx) is False + + def test_comparison_not_equals(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 1}}} + ) + result = evaluate_expression("{{ steps.run-tests.output.exit_code != 0 }}", ctx) + assert result is True + + def test_numeric_comparison(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"plan": {"output": {"task_count": 7}}} + ) + assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True + assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False + + def test_boolean_and(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"a": True, "b": True}) + assert evaluate_expression("{{ inputs.a and inputs.b }}", ctx) is True + + def test_boolean_or(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"a": False, "b": True}) + assert evaluate_expression("{{ inputs.a or inputs.b }}", ctx) is True + + def test_filter_default(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ inputs.missing | default('fallback') }}", ctx) == "fallback" + + def test_filter_join(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"tags": ["a", "b", "c"]}) + assert evaluate_expression("{{ inputs.tags | join(', ') }}", ctx) == "a, b, c" + + def test_filter_contains(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"text": "hello world"}) + assert evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True + + def test_condition_evaluation(self): + from specify_cli.workflows.expressions import evaluate_condition + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"ready": True}) + assert evaluate_condition("{{ inputs.ready }}", ctx) is True + assert evaluate_condition("{{ inputs.missing }}", ctx) is False + + def test_non_string_passthrough(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression(42, ctx) == 42 + assert evaluate_expression(None, ctx) is None + + def test_string_literal(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ 'hello' }}", ctx) == "hello" + + def test_numeric_literal(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ 42 }}", ctx) == 42 + + def test_boolean_literal(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ true }}", ctx) is True + assert evaluate_expression("{{ false }}", ctx) is False + + def test_list_indexing(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"tasks": {"output": {"task_list": [{"file": "a.md"}, {"file": "b.md"}]}}} + ) + result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx) + assert result == "a.md" + + +# ===== Integration Dispatch Tests ===== + +class TestBuildExecArgs: + """Test build_exec_args for CLI-based integrations.""" + + def test_claude_exec_args(self): + from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() + args = impl.build_exec_args("do stuff", model="sonnet-4") + assert args[0] == "claude" + assert args[1] == "-p" + assert args[2] == "do stuff" + assert "--model" in args + assert "sonnet-4" in args + assert "--output-format" in args + + def test_gemini_exec_args(self): + from specify_cli.integrations.gemini import GeminiIntegration + impl = GeminiIntegration() + args = impl.build_exec_args("do stuff", model="gemini-2.5-pro") + assert args[0] == "gemini" + assert args[1] == "-p" + assert "-m" in args + assert "gemini-2.5-pro" in args + + def test_codex_exec_args(self): + from specify_cli.integrations.codex import CodexIntegration + impl = CodexIntegration() + args = impl.build_exec_args("do stuff") + assert args[0] == "codex" + assert args[1] == "exec" + assert args[2] == "do stuff" + assert "--json" in args + + def test_copilot_exec_args(self, monkeypatch): + monkeypatch.delenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", raising=False) + monkeypatch.delenv("SPECKIT_ALLOW_ALL_TOOLS", raising=False) + from specify_cli.integrations.copilot import CopilotIntegration + impl = CopilotIntegration() + args = impl.build_exec_args("do stuff", model="claude-sonnet-4-20250514") + assert args[0] == "copilot" + assert "-p" in args + assert "--yolo" in args + assert "--model" in args + + def test_copilot_new_env_var_disables_yolo(self, monkeypatch): + monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0") + monkeypatch.delenv("SPECKIT_ALLOW_ALL_TOOLS", raising=False) + from specify_cli.integrations.copilot import CopilotIntegration + impl = CopilotIntegration() + args = impl.build_exec_args("do stuff") + assert "--yolo" not in args + + def test_copilot_deprecated_env_var_still_honoured(self, monkeypatch): + monkeypatch.delenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", raising=False) + monkeypatch.setenv("SPECKIT_ALLOW_ALL_TOOLS", "0") + import warnings + from specify_cli.integrations.copilot import CopilotIntegration + impl = CopilotIntegration() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + args = impl.build_exec_args("do stuff") + assert "--yolo" not in args + assert any( + "SPECKIT_ALLOW_ALL_TOOLS is deprecated" in str(x.message) + and issubclass(x.category, UserWarning) + for x in w + ) + + def test_copilot_new_env_var_takes_precedence(self, monkeypatch): + monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "1") + monkeypatch.setenv("SPECKIT_ALLOW_ALL_TOOLS", "0") + from specify_cli.integrations.copilot import CopilotIntegration + impl = CopilotIntegration() + args = impl.build_exec_args("do stuff") + assert "--yolo" in args + + def test_ide_only_returns_none(self): + from specify_cli.integrations.windsurf import WindsurfIntegration + impl = WindsurfIntegration() + assert impl.build_exec_args("test") is None + + def test_no_model_omits_flag(self): + from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() + args = impl.build_exec_args("do stuff", model=None) + assert "--model" not in args + + def test_no_json_omits_flag(self): + from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() + args = impl.build_exec_args("do stuff", output_json=False) + assert "--output-format" not in args + + +# ===== Step Type Tests ===== + +class TestCommandStep: + """Test the command step type.""" + + def test_execute_basic(self): + from unittest.mock import patch + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={"name": "login"}, + default_integration="claude", + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "{{ inputs.name }}"}, + } + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["command"] == "speckit.specify" + assert result.output["integration"] == "claude" + assert result.output["input"]["args"] == "login" + + def test_validate_missing_command(self): + from specify_cli.workflows.steps.command import CommandStep + + step = CommandStep() + errors = step.validate({"id": "test"}) + assert any("missing 'command'" in e for e in errors) + + def test_step_override_integration(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext + + step = CommandStep() + ctx = StepContext(default_integration="claude") + config = { + "id": "test", + "command": "speckit.plan", + "integration": "gemini", + "input": {}, + } + result = step.execute(config, ctx) + assert result.output["integration"] == "gemini" + + def test_step_override_model(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext + + step = CommandStep() + ctx = StepContext(default_model="sonnet-4") + config = { + "id": "test", + "command": "speckit.implement", + "model": "opus-4", + "input": {}, + } + result = step.execute(config, ctx) + assert result.output["model"] == "opus-4" + + def test_options_merge(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext + + step = CommandStep() + ctx = StepContext(default_options={"max-tokens": 8000}) + config = { + "id": "test", + "command": "speckit.plan", + "options": {"thinking-budget": 32768}, + "input": {}, + } + result = step.execute(config, ctx) + assert result.output["options"]["max-tokens"] == 8000 + assert result.output["options"]["thinking-budget"] == 32768 + + def test_dispatch_not_attempted_without_cli(self): + """When the CLI tool is not installed, step should fail.""" + from unittest.mock import patch + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={"name": "login"}, + default_integration="claude", + project_root="/tmp", + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "{{ inputs.name }}"}, + } + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["dispatched"] is False + assert result.error is not None + + def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch): + """When the CLI is installed, dispatch invokes the command by name.""" + from unittest.mock import patch, MagicMock + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={"name": "login"}, + default_integration="claude", + project_root=str(tmp_path), + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "{{ inputs.name }}"}, + } + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = '{"result": "done"}' + mock_result.stderr = "" + + with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result) as mock_run: + result = step.execute(config, ctx) + + assert result.status == StepStatus.COMPLETED + assert result.output["dispatched"] is True + assert result.output["exit_code"] == 0 + # Verify the CLI was called with -p and the skill invocation + call_args = mock_run.call_args + assert call_args[0][0][0] == "claude" + assert call_args[0][0][1] == "-p" + # Claude is a SkillsIntegration so uses /speckit-specify + assert "/speckit-specify login" in call_args[0][0][2] + + def test_dispatch_failure_returns_failed_status(self, tmp_path): + """When the CLI exits non-zero, the step should fail.""" + from unittest.mock import patch, MagicMock + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={}, + default_integration="claude", + project_root=str(tmp_path), + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "test"}, + } + + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "API error" + + with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result): + result = step.execute(config, ctx) + + assert result.status == StepStatus.FAILED + assert result.output["dispatched"] is True + assert result.output["exit_code"] == 1 + + +class TestPromptStep: + """Test the prompt step type.""" + + def test_execute_basic(self): + from unittest.mock import patch + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = PromptStep() + ctx = StepContext( + inputs={"file": "auth.py"}, + default_integration="claude", + ) + config = { + "id": "review", + "type": "prompt", + "prompt": "Review {{ inputs.file }} for security issues", + } + with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value=None): + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["prompt"] == "Review auth.py for security issues" + assert result.output["integration"] == "claude" + assert result.output["dispatched"] is False + + def test_execute_with_step_integration(self): + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext + + step = PromptStep() + ctx = StepContext(default_integration="claude") + config = { + "id": "review", + "type": "prompt", + "prompt": "Summarize the codebase", + "integration": "gemini", + } + result = step.execute(config, ctx) + assert result.output["integration"] == "gemini" + + def test_execute_with_model(self): + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext + + step = PromptStep() + ctx = StepContext(default_integration="claude", default_model="sonnet-4") + config = { + "id": "review", + "type": "prompt", + "prompt": "hello", + "model": "opus-4", + } + result = step.execute(config, ctx) + assert result.output["model"] == "opus-4" + + def test_dispatch_with_mock_cli(self, tmp_path): + from unittest.mock import patch, MagicMock + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = PromptStep() + ctx = StepContext( + default_integration="claude", + project_root=str(tmp_path), + ) + config = { + "id": "ask", + "type": "prompt", + "prompt": "Explain this code", + } + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Here is the explanation" + mock_result.stderr = "" + + with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result): + result = step.execute(config, ctx) + + assert result.status == StepStatus.COMPLETED + assert result.output["dispatched"] is True + assert result.output["exit_code"] == 0 + + def test_validate_missing_prompt(self): + from specify_cli.workflows.steps.prompt import PromptStep + + step = PromptStep() + errors = step.validate({"id": "test"}) + assert any("missing 'prompt'" in e for e in errors) + + def test_validate_valid(self): + from specify_cli.workflows.steps.prompt import PromptStep + + step = PromptStep() + errors = step.validate({"id": "test", "prompt": "do something"}) + assert errors == [] + + +class TestShellStep: + """Test the shell step type.""" + + def test_execute_echo(self): + from specify_cli.workflows.steps.shell import ShellStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = ShellStep() + ctx = StepContext() + config = {"id": "test", "run": "echo hello"} + result = step.execute(config, ctx) + assert result.status == StepStatus.COMPLETED + assert result.output["exit_code"] == 0 + assert "hello" in result.output["stdout"] + + def test_execute_failure(self): + from specify_cli.workflows.steps.shell import ShellStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = ShellStep() + ctx = StepContext() + config = {"id": "test", "run": "exit 1"} + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["exit_code"] == 1 + assert result.error is not None + + def test_validate_missing_run(self): + from specify_cli.workflows.steps.shell import ShellStep + + step = ShellStep() + errors = step.validate({"id": "test"}) + assert any("missing 'run'" in e for e in errors) + + +class TestGateStep: + """Test the gate step type.""" + + def test_execute_returns_paused(self): + from specify_cli.workflows.steps.gate import GateStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = GateStep() + ctx = StepContext() + config = { + "id": "review", + "message": "Review the spec.", + "options": ["approve", "reject"], + "on_reject": "abort", + } + result = step.execute(config, ctx) + assert result.status == StepStatus.PAUSED + assert result.output["message"] == "Review the spec." + assert result.output["options"] == ["approve", "reject"] + + def test_validate_missing_message(self): + from specify_cli.workflows.steps.gate import GateStep + + step = GateStep() + errors = step.validate({"id": "test", "options": ["approve"]}) + assert any("missing 'message'" in e for e in errors) + + def test_validate_invalid_on_reject(self): + from specify_cli.workflows.steps.gate import GateStep + + step = GateStep() + errors = step.validate({ + "id": "test", + "message": "Review", + "on_reject": "invalid", + }) + assert any("on_reject" in e for e in errors) + + +class TestIfThenStep: + """Test the if/then/else step type.""" + + def test_execute_then_branch(self): + from specify_cli.workflows.steps.if_then import IfThenStep + from specify_cli.workflows.base import StepContext + + step = IfThenStep() + ctx = StepContext(inputs={"scope": "full"}) + config = { + "id": "check", + "condition": "{{ inputs.scope == 'full' }}", + "then": [{"id": "a", "command": "speckit.tasks"}], + "else": [{"id": "b", "command": "speckit.plan"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is True + assert len(result.next_steps) == 1 + assert result.next_steps[0]["id"] == "a" + + def test_execute_else_branch(self): + from specify_cli.workflows.steps.if_then import IfThenStep + from specify_cli.workflows.base import StepContext + + step = IfThenStep() + ctx = StepContext(inputs={"scope": "backend"}) + config = { + "id": "check", + "condition": "{{ inputs.scope == 'full' }}", + "then": [{"id": "a", "command": "speckit.tasks"}], + "else": [{"id": "b", "command": "speckit.plan"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is False + assert result.next_steps[0]["id"] == "b" + + def test_validate_missing_condition(self): + from specify_cli.workflows.steps.if_then import IfThenStep + + step = IfThenStep() + errors = step.validate({"id": "test", "then": []}) + assert any("missing 'condition'" in e for e in errors) + + +class TestSwitchStep: + """Test the switch step type.""" + + def test_execute_matches_case(self): + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + step = SwitchStep() + ctx = StepContext( + steps={"review": {"output": {"choice": "approve"}}} + ) + config = { + "id": "route", + "expression": "{{ steps.review.output.choice }}", + "cases": { + "approve": [{"id": "plan", "command": "speckit.plan"}], + "reject": [{"id": "log", "type": "shell", "run": "echo rejected"}], + }, + "default": [{"id": "abort", "type": "gate", "message": "Unknown"}], + } + result = step.execute(config, ctx) + assert result.output["matched_case"] == "approve" + assert result.next_steps[0]["id"] == "plan" + + def test_execute_falls_to_default(self): + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + step = SwitchStep() + ctx = StepContext( + steps={"review": {"output": {"choice": "unknown"}}} + ) + config = { + "id": "route", + "expression": "{{ steps.review.output.choice }}", + "cases": { + "approve": [{"id": "plan", "command": "speckit.plan"}], + }, + "default": [{"id": "fallback", "type": "gate", "message": "Fallback"}], + } + result = step.execute(config, ctx) + assert result.output["matched_case"] == "__default__" + assert result.next_steps[0]["id"] == "fallback" + + def test_execute_no_default_no_match(self): + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + step = SwitchStep() + ctx = StepContext( + steps={"review": {"output": {"choice": "other"}}} + ) + config = { + "id": "route", + "expression": "{{ steps.review.output.choice }}", + "cases": { + "approve": [{"id": "plan", "command": "speckit.plan"}], + }, + } + result = step.execute(config, ctx) + assert result.output["matched_case"] == "__default__" + assert result.next_steps == [] + + def test_validate_missing_expression(self): + from specify_cli.workflows.steps.switch import SwitchStep + + step = SwitchStep() + errors = step.validate({"id": "test", "cases": {}}) + assert any("missing 'expression'" in e for e in errors) + + def test_validate_invalid_cases_and_default(self): + from specify_cli.workflows.steps.switch import SwitchStep + + step = SwitchStep() + errors = step.validate({ + "id": "test", + "expression": "{{ x }}", + "cases": {"a": "not-a-list"}, + "default": "also-bad", + }) + assert any("case 'a' must be a list" in e for e in errors) + assert any("'default' must be a list" in e for e in errors) + + +class TestWhileStep: + """Test the while loop step type.""" + + def test_execute_condition_true(self): + from specify_cli.workflows.steps.while_loop import WhileStep + from specify_cli.workflows.base import StepContext + + step = WhileStep() + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 1}}} + ) + config = { + "id": "retry", + "condition": "{{ steps.run-tests.output.exit_code != 0 }}", + "max_iterations": 5, + "steps": [{"id": "fix", "command": "speckit.implement"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is True + assert len(result.next_steps) == 1 + + def test_execute_condition_false(self): + from specify_cli.workflows.steps.while_loop import WhileStep + from specify_cli.workflows.base import StepContext + + step = WhileStep() + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 0}}} + ) + config = { + "id": "retry", + "condition": "{{ steps.run-tests.output.exit_code != 0 }}", + "max_iterations": 5, + "steps": [{"id": "fix", "command": "speckit.implement"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is False + assert result.next_steps == [] + + def test_validate_missing_fields(self): + from specify_cli.workflows.steps.while_loop import WhileStep + + step = WhileStep() + errors = step.validate({"id": "test", "steps": []}) + assert any("missing 'condition'" in e for e in errors) + # max_iterations is optional (defaults to 10) + + def test_validate_invalid_max_iterations(self): + from specify_cli.workflows.steps.while_loop import WhileStep + + step = WhileStep() + errors = step.validate({"id": "test", "condition": "{{ true }}", "max_iterations": 0, "steps": []}) + assert any("must be an integer >= 1" in e for e in errors) + + +class TestDoWhileStep: + """Test the do-while loop step type.""" + + def test_execute_always_runs_once(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + from specify_cli.workflows.base import StepContext + + step = DoWhileStep() + ctx = StepContext() + config = { + "id": "cycle", + "condition": "{{ false }}", + "max_iterations": 3, + "steps": [{"id": "refine", "command": "speckit.specify"}], + } + result = step.execute(config, ctx) + assert len(result.next_steps) == 1 + assert result.output["loop_type"] == "do-while" + assert result.output["condition"] == "{{ false }}" + + def test_execute_with_true_condition(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + from specify_cli.workflows.base import StepContext + + step = DoWhileStep() + ctx = StepContext() + config = { + "id": "cycle", + "condition": "{{ true }}", + "max_iterations": 5, + "steps": [{"id": "work", "command": "speckit.plan"}], + } + result = step.execute(config, ctx) + # Body always executes on first call regardless of condition + assert len(result.next_steps) == 1 + assert result.output["max_iterations"] == 5 + + def test_execute_empty_steps(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + from specify_cli.workflows.base import StepContext + + step = DoWhileStep() + ctx = StepContext() + config = { + "id": "empty", + "condition": "{{ false }}", + "max_iterations": 1, + "steps": [], + } + result = step.execute(config, ctx) + assert result.next_steps == [] + assert result.status.value == "completed" + + def test_validate_missing_fields(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + errors = step.validate({"id": "test", "steps": []}) + assert any("missing 'condition'" in e for e in errors) + # max_iterations is optional (defaults to 10) + + def test_validate_steps_not_list(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + errors = step.validate({ + "id": "test", + "condition": "{{ true }}", + "max_iterations": 3, + "steps": "not-a-list", + }) + assert any("'steps' must be a list" in e for e in errors) + + +class TestFanOutStep: + """Test the fan-out step type.""" + + def test_execute_with_items(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + from specify_cli.workflows.base import StepContext + + step = FanOutStep() + ctx = StepContext( + steps={"tasks": {"output": {"task_list": [ + {"file": "a.md"}, + {"file": "b.md"}, + ]}}} + ) + config = { + "id": "parallel", + "items": "{{ steps.tasks.output.task_list }}", + "max_concurrency": 3, + "step": {"id": "impl", "command": "speckit.implement"}, + } + result = step.execute(config, ctx) + assert result.output["item_count"] == 2 + assert result.output["max_concurrency"] == 3 + + def test_execute_non_list_items_resolves_empty(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + from specify_cli.workflows.base import StepContext + + step = FanOutStep() + ctx = StepContext() + config = { + "id": "parallel", + "items": "{{ undefined_var }}", + "step": {"id": "impl", "command": "speckit.implement"}, + } + result = step.execute(config, ctx) + assert result.output["item_count"] == 0 + assert result.output["items"] == [] + + def test_validate_missing_fields(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + + step = FanOutStep() + errors = step.validate({"id": "test"}) + assert any("missing 'items'" in e for e in errors) + assert any("missing 'step'" in e for e in errors) + + def test_validate_step_not_mapping(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + + step = FanOutStep() + errors = step.validate({ + "id": "test", + "items": "{{ x }}", + "step": "not-a-dict", + }) + assert any("'step' must be a mapping" in e for e in errors) + + +class TestFanInStep: + """Test the fan-in step type.""" + + def test_execute_collects_results(self): + from specify_cli.workflows.steps.fan_in import FanInStep + from specify_cli.workflows.base import StepContext + + step = FanInStep() + ctx = StepContext( + steps={ + "parallel": {"output": {"item_count": 2, "status": "done"}} + } + ) + config = { + "id": "collect", + "wait_for": ["parallel"], + "output": {}, + } + result = step.execute(config, ctx) + assert len(result.output["results"]) == 1 + assert result.output["results"][0]["item_count"] == 2 + + def test_execute_multiple_wait_for(self): + from specify_cli.workflows.steps.fan_in import FanInStep + from specify_cli.workflows.base import StepContext + + step = FanInStep() + ctx = StepContext( + steps={ + "task-a": {"output": {"file": "a.md"}}, + "task-b": {"output": {"file": "b.md"}}, + } + ) + config = { + "id": "collect", + "wait_for": ["task-a", "task-b"], + "output": {}, + } + result = step.execute(config, ctx) + assert len(result.output["results"]) == 2 + assert result.output["results"][0]["file"] == "a.md" + assert result.output["results"][1]["file"] == "b.md" + + def test_execute_missing_wait_for_step(self): + from specify_cli.workflows.steps.fan_in import FanInStep + from specify_cli.workflows.base import StepContext + + step = FanInStep() + ctx = StepContext(steps={}) + config = { + "id": "collect", + "wait_for": ["nonexistent"], + "output": {}, + } + result = step.execute(config, ctx) + assert result.output["results"] == [{}] + + def test_validate_empty_wait_for(self): + from specify_cli.workflows.steps.fan_in import FanInStep + + step = FanInStep() + errors = step.validate({"id": "test", "wait_for": []}) + assert any("non-empty list" in e for e in errors) + + def test_validate_wait_for_not_list(self): + from specify_cli.workflows.steps.fan_in import FanInStep + + step = FanInStep() + errors = step.validate({"id": "test", "wait_for": "not-a-list"}) + assert any("non-empty list" in e for e in errors) + + +# ===== Workflow Definition Tests ===== + +class TestWorkflowDefinition: + """Test WorkflowDefinition loading and parsing.""" + + def test_from_yaml(self, sample_workflow_file): + from specify_cli.workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_yaml(sample_workflow_file) + assert definition.id == "test-workflow" + assert definition.name == "Test Workflow" + assert definition.version == "1.0.0" + assert len(definition.steps) == 2 + + def test_from_string(self, sample_workflow_yaml): + from specify_cli.workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_string(sample_workflow_yaml) + assert definition.id == "test-workflow" + assert len(definition.inputs) == 2 + + def test_from_string_invalid(self): + from specify_cli.workflows.engine import WorkflowDefinition + + with pytest.raises(ValueError, match="must be a mapping"): + WorkflowDefinition.from_string("- just a list") + + def test_inputs_parsed(self, sample_workflow_yaml): + from specify_cli.workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_string(sample_workflow_yaml) + assert "spec" in definition.inputs + assert definition.inputs["spec"]["required"] is True + assert definition.inputs["scope"]["default"] == "full" + + +# ===== Workflow Validation Tests ===== + +class TestWorkflowValidation: + """Test workflow validation.""" + + def test_valid_workflow(self, sample_workflow_yaml): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(sample_workflow_yaml) + errors = validate_workflow(definition) + assert errors == [] + + def test_missing_id(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + name: "Test" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("workflow.id" in e for e in errors) + + def test_invalid_id_format(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "Invalid ID!" + name: "Test" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("lowercase alphanumeric" in e for e in errors) + + def test_no_steps(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: [] +""") + errors = validate_workflow(definition) + assert any("no steps" in e.lower() for e in errors) + + def test_duplicate_step_ids(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: same-id + command: speckit.specify + - id: same-id + command: speckit.plan +""") + errors = validate_workflow(definition) + assert any("Duplicate" in e for e in errors) + + def test_invalid_step_type(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: bad + type: nonexistent +""") + errors = validate_workflow(definition) + assert any("invalid type" in e.lower() for e in errors) + + def test_nested_step_validation(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: branch + type: if + condition: "{{ true }}" + then: + - id: nested-a + command: speckit.specify + else: + - id: nested-b + command: speckit.plan +""") + errors = validate_workflow(definition) + assert errors == [] + + def test_invalid_input_type(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +inputs: + bad: + type: array +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("invalid type" in e.lower() for e in errors) + + +# ===== Workflow Engine Tests ===== + +class TestWorkflowEngine: + """Test WorkflowEngine execution.""" + + def test_load_from_file(self, sample_workflow_file, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + definition = engine.load_workflow(str(sample_workflow_file)) + assert definition.id == "test-workflow" + + def test_load_from_installed_id(self, sample_workflow_file, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + definition = engine.load_workflow("test-workflow") + assert definition.id == "test-workflow" + + def test_load_not_found(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + with pytest.raises(FileNotFoundError): + engine.load_workflow("nonexistent") + + def test_execute_simple_workflow(self, project_dir): + from unittest.mock import patch + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "simple" + name: "Simple" + version: "1.0.0" + integration: claude +inputs: + name: + type: string + default: "test" +steps: + - id: step-one + command: speckit.specify + input: + args: "{{ inputs.name }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None): + state = engine.execute(definition, {"name": "login"}) + + assert state.status == RunStatus.FAILED + assert "step-one" in state.step_results + assert state.step_results["step-one"]["output"]["command"] == "speckit.specify" + assert state.step_results["step-one"]["output"]["input"]["args"] == "login" + + def test_execute_with_gate_pauses(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "gated" + name: "Gated" + version: "1.0.0" +steps: + - id: step-one + type: shell + run: "echo test" + - id: gate + type: gate + message: "Review?" + options: [approve, reject] + on_reject: abort + - id: step-two + type: shell + run: "echo done" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.PAUSED + assert "gate" in state.step_results + assert state.step_results["gate"]["status"] == "paused" + + def test_execute_with_shell_step(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "shell-test" + name: "Shell Test" + version: "1.0.0" +steps: + - id: echo + type: shell + run: "echo workflow-output" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.COMPLETED + assert "workflow-output" in state.step_results["echo"]["output"]["stdout"] + + def test_execute_with_if_then(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "branching" + name: "Branching" + version: "1.0.0" +inputs: + scope: + type: string + default: "full" +steps: + - id: check + type: if + condition: "{{ inputs.scope == 'full' }}" + then: + - id: full-tasks + type: shell + run: "echo full" + else: + - id: partial-tasks + type: shell + run: "echo partial" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition, {"scope": "full"}) + + assert state.status == RunStatus.COMPLETED + assert "full-tasks" in state.step_results + assert "partial-tasks" not in state.step_results + + def test_execute_missing_required_input(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "needs-input" + name: "Needs Input" + version: "1.0.0" +inputs: + name: + type: string + required: true +steps: + - id: step-one + command: speckit.specify + input: + args: "{{ inputs.name }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + + with pytest.raises(ValueError, match="Required input"): + engine.execute(definition, {}) + + +# ===== State Persistence Tests ===== + +class TestRunState: + """Test RunState persistence and loading.""" + + def test_save_and_load(self, project_dir): + from specify_cli.workflows.engine import RunState + from specify_cli.workflows.base import RunStatus + + state = RunState( + run_id="test-run", + workflow_id="test-workflow", + project_root=project_dir, + ) + state.status = RunStatus.RUNNING + state.inputs = {"name": "login"} + state.step_results = { + "step-one": { + "output": {"file": "spec.md"}, + "status": "completed", + } + } + state.save() + + loaded = RunState.load("test-run", project_dir) + assert loaded.run_id == "test-run" + assert loaded.workflow_id == "test-workflow" + assert loaded.status == RunStatus.RUNNING + assert loaded.inputs == {"name": "login"} + assert "step-one" in loaded.step_results + + def test_load_not_found(self, project_dir): + from specify_cli.workflows.engine import RunState + + with pytest.raises(FileNotFoundError): + RunState.load("nonexistent", project_dir) + + def test_append_log(self, project_dir): + from specify_cli.workflows.engine import RunState + + state = RunState( + run_id="log-test", + workflow_id="test", + project_root=project_dir, + ) + state.append_log({"event": "test_event", "data": "hello"}) + + log_file = state.runs_dir / "log.jsonl" + assert log_file.exists() + lines = log_file.read_text().strip().split("\n") + entry = json.loads(lines[0]) + assert entry["event"] == "test_event" + assert "timestamp" in entry + + +class TestListRuns: + """Test listing workflow runs.""" + + def test_list_empty(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + assert engine.list_runs() == [] + + def test_list_after_execution(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "list-test" + name: "List Test" + version: "1.0.0" +steps: + - id: step-one + type: shell + run: "echo test" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + engine.execute(definition) + + runs = engine.list_runs() + assert len(runs) == 1 + assert runs[0]["workflow_id"] == "list-test" + + +# ===== Workflow Registry Tests ===== + +class TestWorkflowRegistry: + """Test WorkflowRegistry operations.""" + + def test_add_and_get(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test", "version": "1.0.0"}) + + entry = registry.get("test-wf") + assert entry is not None + assert entry["name"] == "Test" + assert "installed_at" in entry + + def test_remove(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test"}) + assert registry.is_installed("test-wf") + + registry.remove("test-wf") + assert not registry.is_installed("test-wf") + + def test_list(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("wf-a", {"name": "A"}) + registry.add("wf-b", {"name": "B"}) + + installed = registry.list() + assert "wf-a" in installed + assert "wf-b" in installed + + def test_is_installed(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + assert not registry.is_installed("missing") + + registry.add("exists", {"name": "Exists"}) + assert registry.is_installed("exists") + + def test_persistence(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry1 = WorkflowRegistry(project_dir) + registry1.add("test-wf", {"name": "Test"}) + + # Load fresh + registry2 = WorkflowRegistry(project_dir) + assert registry2.is_installed("test-wf") + + +# ===== Workflow Catalog Tests ===== + +class TestWorkflowCatalog: + """Test WorkflowCatalog catalog resolution.""" + + def test_default_catalogs(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + entries = catalog.get_active_catalogs() + assert len(entries) == 2 + assert entries[0].name == "default" + assert entries[1].name == "community" + + def test_env_var_override(self, project_dir, monkeypatch): + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.setenv("SPECKIT_WORKFLOW_CATALOG_URL", "https://example.com/catalog.json") + catalog = WorkflowCatalog(project_dir) + entries = catalog.get_active_catalogs() + assert len(entries) == 1 + assert entries[0].name == "env-override" + assert entries[0].url == "https://example.com/catalog.json" + + def test_project_level_config(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [{ + "name": "custom", + "url": "https://example.com/wf-catalog.json", + "priority": 1, + "install_allowed": True, + }] + })) + + catalog = WorkflowCatalog(project_dir) + entries = catalog.get_active_catalogs() + assert len(entries) == 1 + assert entries[0].name == "custom" + + def test_validate_url_http_rejected(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + catalog = WorkflowCatalog(project_dir) + with pytest.raises(WorkflowValidationError, match="HTTPS"): + catalog._validate_catalog_url("http://evil.com/catalog.json") + + def test_validate_url_localhost_http_allowed(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + # Should not raise + catalog._validate_catalog_url("http://localhost:8080/catalog.json") + + def test_add_catalog(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/new-catalog.json", "my-catalog") + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + assert config_path.exists() + data = yaml.safe_load(config_path.read_text()) + assert len(data["catalogs"]) == 1 + assert data["catalogs"][0]["url"] == "https://example.com/new-catalog.json" + + def test_add_catalog_duplicate_rejected(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/catalog.json") + + with pytest.raises(WorkflowValidationError, match="already configured"): + catalog.add_catalog("https://example.com/catalog.json") + + def test_remove_catalog(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/c1.json", "first") + catalog.add_catalog("https://example.com/c2.json", "second") + + removed = catalog.remove_catalog(0) + assert removed == "first" + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + data = yaml.safe_load(config_path.read_text()) + assert len(data["catalogs"]) == 1 + + def test_remove_catalog_invalid_index(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/c1.json") + + with pytest.raises(WorkflowValidationError, match="out of range"): + catalog.remove_catalog(5) + + def test_get_catalog_configs(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + configs = catalog.get_catalog_configs() + assert len(configs) == 2 + assert configs[0]["name"] == "default" + assert isinstance(configs[0]["install_allowed"], bool) + + +# ===== Integration Test ===== + +class TestWorkflowIntegration: + """End-to-end workflow execution tests.""" + + def test_full_sequential_workflow(self, project_dir): + """Execute a multi-step sequential workflow end to end.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "e2e-test" + name: "E2E Test" + version: "1.0.0" + integration: claude +inputs: + feature: + type: string + default: "login" +steps: + - id: specify + type: shell + run: "echo speckit.specify {{ inputs.feature }}" + + - id: check-scope + type: if + condition: "{{ inputs.feature == 'login' }}" + then: + - id: echo-full + type: shell + run: "echo full scope" + else: + - id: echo-partial + type: shell + run: "echo partial scope" + + - id: plan + type: shell + run: "echo speckit.plan" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.COMPLETED + assert "specify" in state.step_results + assert "check-scope" in state.step_results + assert "echo-full" in state.step_results + assert "echo-partial" not in state.step_results + assert "plan" in state.step_results + + def test_switch_workflow(self, project_dir): + """Test switch step type in a workflow.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "switch-test" + name: "Switch Test" + version: "1.0.0" +inputs: + action: + type: string + default: "plan" +steps: + - id: route + type: switch + expression: "{{ inputs.action }}" + cases: + specify: + - id: do-specify + type: shell + run: "echo specify" + plan: + - id: do-plan + type: shell + run: "echo plan" + default: + - id: do-default + type: shell + run: "echo default" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.COMPLETED + assert "do-plan" in state.step_results + assert "do-specify" not in state.step_results diff --git a/workflows/ARCHITECTURE.md b/workflows/ARCHITECTURE.md new file mode 100644 index 0000000000..892333473c --- /dev/null +++ b/workflows/ARCHITECTURE.md @@ -0,0 +1,211 @@ +# Workflow System Architecture + +This document describes the internal architecture of the workflow engine — how definitions are parsed, steps are dispatched, state is persisted, and catalogs are resolved. + +For usage instructions, see [README.md](README.md). + +## Execution Model + +When `specify workflow run` is invoked, the engine loads a YAML definition, resolves inputs, and dispatches steps sequentially through the step registry: + +```mermaid +flowchart TD + A["specify workflow run my-workflow"] --> 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 }}"