diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 4dd17294e7..5aa0a076c1 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -56,7 +56,7 @@ run_command "npm install -g @jetbrains/junie-cli@latest" echo "βœ… Done" echo -e "\nπŸ€– Installing Pi Coding Agent..." -run_command "npm install -g @mariozechner/pi-coding-agent@latest" +run_command "npm install -g @earendil-works/pi-coding-agent@latest" echo "βœ… Done" echo -e "\nπŸ€– Installing Kiro CLI..." @@ -88,9 +88,9 @@ fi run_command "$kiro_binary --help > /dev/null" echo "βœ… Done" -echo -e "\nπŸ€– Installing Kimi CLI..." +echo -e "\nπŸ€– Installing Kimi Code CLI..." # https://code.kimi.com -run_command "pipx install kimi-cli" +run_command "npm install -g @moonshot-ai/kimi-code@latest" echo "βœ… Done" echo -e "\nπŸ€– Installing CodeBuddy CLI..." diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index d9ed95eb55..69cfd090e6 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**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed + **Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed - type: input id: agent-name diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 59f7e9eaf8..227f98ae1c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -85,6 +85,7 @@ body: - Kiro CLI - Lingma - Mistral Vibe + - Oh My Pi - opencode - Pi Coding Agent - Qoder CLI diff --git a/.github/ISSUE_TEMPLATE/bundle_submission.yml b/.github/ISSUE_TEMPLATE/bundle_submission.yml new file mode 100644 index 0000000000..c2b928f3a7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bundle_submission.yml @@ -0,0 +1,293 @@ +name: Bundle Submission +description: Submit your bundle metadata for community catalog validation +title: "[Bundle]: Add " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for contributing a bundle! This template captures metadata for maintainers to validate formatting, links, component resolution, and installation evidence. Maintainers do not audit, endorse, or support bundle code or installed components. + + **Before submitting:** + - Review the [Bundles reference](https://github.com/github/spec-kit/blob/main/docs/reference/bundles.md) + - Ensure your bundle has a valid `bundle.yml` manifest + - Create a GitHub release with a versioned bundle artifact + - Test installation from a downloaded artifact: `specify bundle install ./your-bundle-1.0.0.zip` + - If you host a bundle catalog, test catalog installation with `specify bundle catalog add --id --policy install-allowed` and `specify bundle install ` + - If your bundle depends on components from non-default catalogs, document those catalog URLs and test installation from a clean project + + - type: input + id: bundle-id + attributes: + label: Bundle ID + description: Unique bundle identifier; must start and end with a lowercase letter or digit and may contain lowercase letters, digits, dots, underscores, and hyphens between + placeholder: "e.g., security-governance-stack" + validations: + required: true + + - type: input + id: bundle-name + attributes: + label: Bundle Name + description: Human-readable bundle name + placeholder: "e.g., Security Governance Stack" + validations: + required: true + + - type: input + id: version + attributes: + label: Version + description: Semantic version number + placeholder: "e.g., 1.0.0" + validations: + required: true + + - type: input + id: role + attributes: + label: Role or Team + description: Primary role, team, or persona this bundle provisions + placeholder: "e.g., security-engineer, product-manager, platform-team" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: Brief description of the stack this bundle installs + placeholder: Installs a security governance stack with compliance presets, review commands, and evidence workflows + validations: + required: true + + - type: input + id: author + attributes: + label: Author + description: Your name or organization + placeholder: "e.g., Jane Doe or Acme Corp" + validations: + required: true + + - type: input + id: repository + attributes: + label: Repository URL + description: GitHub repository URL for your bundle source + placeholder: "https://github.com/your-org/spec-kit-bundle-your-bundle" + validations: + required: true + + - type: input + id: download-url + attributes: + label: Download URL + description: URL to the versioned bundle artifact generated by `specify bundle build` + placeholder: "https://github.com/your-org/spec-kit-bundle-your-bundle/releases/download/v1.0.0/your-bundle-1.0.0.zip" + validations: + required: true + + - type: input + id: documentation + attributes: + label: Documentation URL + description: Link to documentation that explains what the bundle installs and how to use it + placeholder: "https://github.com/your-org/spec-kit-bundle-your-bundle/blob/main/README.md" + validations: + required: true + + - type: input + id: license + attributes: + label: License + description: Open source license type + placeholder: "e.g., MIT, Apache-2.0" + validations: + required: true + + - type: input + id: speckit-version + attributes: + label: Required Spec Kit Version + description: Minimum Spec Kit version required by the bundle + placeholder: "e.g., >=0.9.0" + validations: + required: true + + - type: input + id: integration + attributes: + label: Integration Target (optional) + description: Integration ID if the bundle pins one; leave empty if integration-agnostic + placeholder: "e.g., claude, copilot, gemini" + + - type: textarea + id: components-provided + attributes: + label: Components Provided + description: List the extensions, presets, workflows, and steps this bundle installs + placeholder: | + - extensions: sicario-guard@0.5.1 + - presets: sicario-core@0.5.1, sicario-ai-governance@0.5.1 + - workflows: evidence-review@1.0.0 + - steps: threat-model + validations: + required: true + + - type: textarea + id: required-catalogs + attributes: + label: Required Component Catalogs + description: List any non-default catalogs users must add before this bundle can resolve its components; enter "None" if every component resolves from built-in or bundled catalogs + placeholder: | + - Presets: https://github.com/your-org/your-bundle/releases/download/v1.0.0/presets.json + - Extensions: https://github.com/your-org/your-bundle/releases/download/v1.0.0/extensions.json + validations: + required: true + + - type: textarea + id: tags + attributes: + label: Tags + description: 2-5 relevant tags (lowercase, separated by commas) + placeholder: "security, governance, compliance" + validations: + required: true + + - type: textarea + id: features + attributes: + label: Key Features + description: List the main capabilities this bundle provides + placeholder: | + - Installs evidence-first security governance templates + - Adds automated bundle verification commands + - Pins all components to release-tested versions + validations: + required: true + + - type: checkboxes + id: testing + attributes: + label: Testing Checklist + description: Confirm that your bundle has been tested + options: + - label: Validation succeeds with `specify bundle validate --path ` + required: true + - label: Build succeeds with `specify bundle build --path ` and produces the submitted artifact + required: true + - label: Bundle installs successfully from the built artifact + required: true + - label: The submitted distribution path was tested end to end, including bundle-ID installation from an install-allowed catalog when a catalog entry is proposed + required: true + - label: Installation was tested in a clean Spec Kit project + required: true + - label: Required component catalogs are documented and were included in testing, or no extra catalogs are required + required: true + - label: Documentation is complete and accurate + required: true + + - type: checkboxes + id: requirements + attributes: + label: Submission Requirements + description: Verify your bundle meets all requirements + options: + - label: Valid `bundle.yml` manifest included + required: true + - label: README.md explains the bundle's intended role, installed components, and installation steps + required: true + - label: LICENSE file included + required: true + - label: GitHub release created with a version tag + required: true + - label: Bundle ID matches the manifest and follows naming conventions + required: true + - label: Every extension, preset, workflow, and step reference is pinned where the manifest requires a version + required: true + + - type: textarea + id: testing-details + attributes: + label: Testing Details + description: Describe how you tested your bundle + placeholder: | + **Tested on:** + - macOS 15 with Spec Kit v0.9.0 + - Ubuntu 24.04 with Spec Kit v0.9.0 + + **Test project:** [Link or description] + + **Test scenarios:** + 1. Added required catalogs + 2. Validated bundle manifest + 3. Built release artifact + 4. Installed bundle in a clean project + 5. Ran the installed commands or workflows + validations: + required: true + + - type: textarea + id: example-usage + attributes: + label: Example Usage + description: Provide a simple example of installing and using your bundle + render: markdown + placeholder: | + ```bash + # Add any required component catalogs first + specify preset catalog add https://github.com/your-org/your-bundle/releases/download/v1.0.0/presets.json --name your-bundle --install-allowed + specify extension catalog add https://github.com/your-org/your-bundle/releases/download/v1.0.0/extensions.json --name your-bundle --install-allowed + + # Install the downloaded bundle artifact + curl -L -o your-bundle-1.0.0.zip https://github.com/your-org/your-bundle/releases/download/v1.0.0/your-bundle-1.0.0.zip + specify bundle install ./your-bundle-1.0.0.zip + + # Or test through an install-allowed bundle catalog + specify bundle catalog add https://github.com/your-org/your-bundle/releases/download/v1.0.0/bundles.json --id your-bundle-catalog --policy install-allowed + specify bundle install your-bundle + ``` + validations: + required: true + + - type: textarea + id: catalog-entry + attributes: + label: Proposed Catalog Entry + description: Provide the JSON entry that would appear under the top-level `bundles` object in a bundle catalog (helps reviewers) + render: json + placeholder: | + { + "your-bundle": { + "name": "Your Bundle", + "id": "your-bundle", + "version": "1.0.0", + "role": "security-engineer", + "description": "Brief description of the stack", + "author": "Your Name", + "license": "MIT", + "download_url": "https://github.com/your-org/your-bundle/releases/download/v1.0.0/your-bundle-1.0.0.zip", + "repository": "https://github.com/your-org/your-bundle", + "requires": { + "speckit_version": ">=0.9.0" + }, + "provides": { + "extensions": 1, + "presets": 2, + "steps": 0, + "workflows": 1 + }, + "tags": ["security", "governance"], + "verified": false + } + } + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other information that would help reviewers + placeholder: Screenshots, demo videos, links to related projects, dependency-resolution notes, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index dc0e9b83c1..ca1ecb9c11 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -79,6 +79,7 @@ body: - Kiro CLI - Lingma - Mistral Vibe + - Oh My Pi - opencode - Pi Coding Agent - Qoder CLI diff --git a/.github/ISSUE_TEMPLATE/preset_submission.yml b/.github/ISSUE_TEMPLATE/preset_submission.yml index 19244e6651..f25c60f92a 100644 --- a/.github/ISSUE_TEMPLATE/preset_submission.yml +++ b/.github/ISSUE_TEMPLATE/preset_submission.yml @@ -77,6 +77,18 @@ body: validations: required: true + - type: input + id: documentation + attributes: + label: Documentation URL + description: | + Link to the README that explains how to use **this preset** (not a general product/framework pitch). + Prefer the preset-scoped README (e.g. `presets//README.md` in a monorepo) over the repository root README. + It must contain at least one valid `specify preset add ...` install command β€” ideally `specify preset add --from ` using the exact Download URL above (other forms such as `specify preset add ` or `specify preset add --dev ` are also accepted). + placeholder: "https://github.com/your-org/spec-kit-presets/blob/main/presets/your-preset/README.md" + validations: + required: true + - type: input id: license attributes: @@ -175,7 +187,7 @@ body: options: - label: Valid `preset.yml` manifest included required: true - - label: README.md with description and usage instructions + - label: Linked README (Documentation URL) explains how to use this preset and includes a valid `specify preset add ...` command (preferably `specify preset add --from ` using the exact download URL) required: true - label: LICENSE file included required: true diff --git a/.github/workflows/add-community-preset.lock.yml b/.github/workflows/add-community-preset.lock.yml index 9aec9914f1..eae7ba0c9b 100644 --- a/.github/workflows/add-community-preset.lock.yml +++ b/.github/workflows/add-community-preset.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b4ba1db5fdec754fa825cc3160879924118bc454a781eed70ef6c90beab83a95","body_hash":"392ace500b7cb9b0aa6b020d150841de398bcbcfe54dbad729f0d860d698bde2","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b4ba1db5fdec754fa825cc3160879924118bc454a781eed70ef6c90beab83a95","body_hash":"cb6c19088fa13da0a8320c174e8c14c4887d2c8a005a5cb2d2d2faa3f890de39","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]} # This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # diff --git a/.github/workflows/add-community-preset.md b/.github/workflows/add-community-preset.md index bc2a8115e1..a05eed0095 100644 --- a/.github/workflows/add-community-preset.md +++ b/.github/workflows/add-community-preset.md @@ -73,6 +73,7 @@ fields): | Author | `author` | Yes | | Repository URL | `repository` | Yes | | Download URL | `download-url` | Yes | +| Documentation URL | `documentation` | Yes | | License | `license` | Yes | | Required Spec Kit Version | `speckit-version` | Yes | | Required Extensions | `required-extensions` | No | @@ -100,17 +101,70 @@ deciding pass/fail: ### 2c. Repository validation - Fetch the repository URL β€” confirm it exists and is publicly accessible - Confirm the repository contains a `preset.yml` file -- Confirm the repository contains a `README.md` file - Confirm the repository contains a `LICENSE` file -### 2d. Release and download URL validation +> The README requirement is enforced once, in **Step 2d**, against the specific file the +> `documentation` field points to β€” not a generic repository-root `README.md`. This avoids +> the monorepo false-positive where a root README exists but isn't the preset-usage doc. + +### 2d. Documentation README validation + +The `documentation` field must point to the README that explains **how to use this +preset** β€” not just any file named `README.md`, and not a product/framework pitch. + +- **Restrict the URL to GitHub before fetching.** The `documentation` value is + user-provided input. Only accept GitHub-hosted README URLs: + - `https://github.com///blob//` + - `https://github.com///raw//` + - `https://raw.githubusercontent.com////` + + If the URL points anywhere else (or isn't a URL), **fail this check** and do not fetch it. +- **Require the URL to point at a README file.** After stripping any fragment/query (see + below), the URL path must end with `README.md` (case-insensitive). If it points at some + other Markdown file, **fail this check** and ask the submitter to link the preset's README. +- Fetch the **exact URL** in the `documentation` field. First strip any fragment (`#...`) + or query string (`?...`) β€” these are common when copying from the browser UI and must be + ignored so the fetch target is deterministic. Then resolve the raw content to fetch: + - For a `github.com///blob//` URL, fetch the equivalent + `github.com///raw//` URL (only swap `/blob/` β†’ `/raw/`). + - Fetch `github.com/.../raw/...` and `raw.githubusercontent.com/...` URLs as-is. + + Do **not** rewrite into `raw.githubusercontent.com////` form β€” that + format can't reliably represent refs containing slashes (e.g. a `feature/foo` branch). + Confirm the fetched URL resolves to a readable Markdown file. +- **Validate that the README contains a valid Spec Kit CLI install command.** The fetched + README must contain at least one `specify preset add ...` invocation. The strongest + signal is the catalog-install form whose URL matches the submitted **Download URL**: + - `specify preset add --from ` (preferred), or + - `specify preset add `, or + - `specify preset add --dev ` + + A `specify preset add --from ` command only counts when its `` **matches the + submitted Download URL exactly**. A `--from` command pointing at a *different* URL does + **not** satisfy the install-command requirement (treat it as if absent) β€” but the README + may still pass on one of the other accepted forms (`specify preset add ` or + `specify preset add --dev `). + + If **no** accepted `specify preset add ...` command is present, the README is treated as a + generic description/pitch rather than preset-usage documentation β€” **fail this check** and + tell the submitter to add a valid install command (ideally + `specify preset add --from `). +- **Prefer a preset-scoped README in monorepos.** If `documentation` resolves to a generic + repository-root README in a monorepo (the preset lives in a subdirectory such as + `presets//` and a preset-scoped README exists there), **flag it** in your comment and + recommend the submitter point `documentation` at the preset-scoped README + (e.g. `presets//README.md`) so the catalog surfaces usage instead of marketing. Treat + this as a flag rather than a hard failure **only if** the root README still contains a valid + `specify preset add ...` command for this preset; otherwise it fails check 2d above. + +### 2e. Release and download URL validation - The download URL should follow the pattern `https://github.com///archive/refs/tags/v.zip` or `https://github.com///releases/download//.zip` - Verify a GitHub release exists matching the submitted version -### 2e. Submission checklists +### 2f. Submission checklists - Confirm that all required checkboxes in the Testing Checklist and Submission Requirements sections are checked (`[x]`) @@ -154,7 +208,7 @@ Insert the entry in **alphabetical order by preset ID** within the "repository": "", "download_url": "", "homepage": "", - "documentation": "", + "documentation": "", "license": "", "requires": { "speckit_version": "" diff --git a/.github/workflows/catalog-assign.yml b/.github/workflows/catalog-assign.yml index 78b4f552f3..f828794864 100644 --- a/.github/workflows/catalog-assign.yml +++ b/.github/workflows/catalog-assign.yml @@ -19,7 +19,7 @@ jobs: permissions: issues: write steps: - - uses: actions/github-script@v9 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const issue = context.payload.issue; diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 59a02702a1..84074b4791 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -42,3 +42,15 @@ jobs: globs: | '**/*.md' !extensions/**/*.md + + shellcheck: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + # shellcheck is preinstalled on ubuntu-latest runners. + # Start at --severity=error to block real bugs without flagging style + # (notably SC2155). Tighten in a follow-up after cleanup. + - name: Run shellcheck on shell scripts + run: git ls-files -z -- '*.sh' | xargs -0 shellcheck --severity=error diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index a3bfc8fbeb..1abda3e91c 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -35,7 +35,7 @@ jobs: uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.13" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d3169197a..f8dde19633 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.13" @@ -40,7 +40,7 @@ jobs: uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} diff --git a/AGENTS.md b/AGENTS.md index 3d5ea32377..68d8641e4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,6 @@ class WindsurfIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".windsurf/rules/specify-rules.md" ``` **TOML agent (Gemini):** @@ -101,7 +100,6 @@ class GeminiIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "GEMINI.md" ``` **Skills agent (Codex):** @@ -129,7 +127,6 @@ class CodexIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -150,7 +147,6 @@ class CodexIntegration(SkillsIntegration): | `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"`) | **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"`). @@ -175,9 +171,11 @@ def _register_builtins() -> None: ### 4. Context file behavior -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. +The Specify CLI carries **no agent-context state whatsoever**. Integration classes do **not** declare a `context_file`, and the CLI never creates, updates, removes, resolves, or migrates a context/instruction file (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`, …). New integrations add nothing for context handling. -The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`: +Managing the "Spec Kit" section in the context file is fully owned by the bundled `agent-context` extension (`extensions/agent-context/`), which is a **full opt-in**: `specify init` does not install it. A user adds/enables it through the standard extension verbs, after which the extension's own bundled scripts maintain the context section. When the extension is absent or disabled, nothing in Spec Kit touches the context file. + +The extension reads its own config file at `.specify/extensions/agent-context/agent-context-config.yml`: ```yaml # Path to the coding agent context file managed by this extension @@ -189,10 +187,10 @@ context_markers: end: "" ``` -- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run. -- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly β€” both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth. +- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted β€” all agentβ†’context-file knowledge lives inside the extension. +- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly. -Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). +Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run. Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts β€” the `agent-context` extension is fully generic. @@ -401,7 +399,6 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): 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 ## Branch Naming Convention @@ -466,7 +463,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag ## Common Pitfalls 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 context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class β€” markers and dispatcher scripts are managed centrally. +2. **Reintroducing context handling into the CLI**: The opt-in `agent-context` extension owns everything about context files β€” including the per-agent default mapping in `agent-context-defaults.json`. Integration classes must **not** declare a `context_file`, and no CLI code should read, write, resolve, or migrate context files. All context-file logic lives in `.specify/extensions/agent-context/` and its bundled scripts. 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b31f5274..48a1ac9936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,85 @@ +## [0.12.0] - 2026-06-29 + +### Changed + +- feat: make agent-context extension a full opt-in (#3097) +- docs(workflows): add the built-in 'init' step type to the Step Types table (#3234) +- fix(workflows): gate validate() must not crash on non-string options (#3233) +- fix(workflows): make pipe-filter detection quote-aware in expressions (#3232) +- fix(workflows): reject a fan-in wait_for that names an unknown step at validation (#3225) +- fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash) (#3230) +- fix(scripts): count subdirectory-only dirs as non-empty in PowerShell (parity with bash) (#3137) +- fix(scripts): drop HAS_GIT from PowerShell git-extension output (parity with bash) (#3195) +- Update Product Spec Extension to v1.0.1 (#3226) +- chore: release 0.11.10, begin 0.11.11.dev0 development (#3240) + +## [0.11.10] - 2026-06-29 + +### Changed + +- fix(extensions): apply GHES auth and resolve release assets for `extension add --from` (#3217) +- fix(pi): repoint install_url to @earendil-works/pi-coding-agent (#3169) (#3214) +- fix(catalogs): reject host-less catalog URLs in base and preset validators (#3210) +- fix: update CodeBuddy install docs URL (#3187) +- fix(workflows): reject infinite number-input default instead of raising OverflowError (#3199) +- fix(scripts): emit 'Copied plan template' status in setup-plan.ps1 (parity with bash) (#3198) +- fix(workflows): make expression operator/literal parsing quote-aware (#3197) +- fix(scripts): honor explicit -Number 0 in PowerShell create-new-feature (parity with bash) (#3196) +- Add community bundle submission path (#3162) +- Docs: Document /speckit.converge command (#3181) +- chore: release 0.11.9, begin 0.11.10.dev0 development (#3189) + +## [0.11.9] - 2026-06-26 + +### Changed + +- Docs: add cline and zcode to multi-install-safe table (#3180) +- Docs: document missing flags --force and --refresh-shared-infra (#3179) +- fix(claude): stop forking /speckit-analyze to prevent long-session freezes (#3188) +- fix: derive plan path from feature.json in update-agent-context (#3069) +- fix(catalog): companion β†’ README docs, version-pinned download URL, v0.11.0, refreshed tags (#2954) +- chore(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#3173) +- Update SicarioSpec Core preset to v0.5.1 (#3165) +- fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157) +- Update preset composition strategy reference (#3143) +- fix(scripts): keep PowerShell branch-name acronym match case-sensitive (parity with bash) (#3129) +- fix(extensions): tell agent to run mandatory hooks, not just emit the directive (#2901) +- Point sicario-core docs to preset README (#3120) +- chore: release 0.11.8, begin 0.11.9.dev0 development (#3156) + +## [0.11.8] - 2026-06-24 + +### Changed + +- docs: add SpecKit Assistant npm package to Community Friends (#3142) +- Require preset-usage README with Spec Kit CLI syntax in preset submissions (#3104) +- [extension] Update Jira Integration (Sync Engine) extension to v0.4.0 (#3152) +- Add Spec Roadmap extension to community catalog (#3153) +- feat(integration): update Kimi integration for Kimi Code CLI (#2979) +- [extension] Add Golden Demo extension to community catalog (#3151) +- docs: run /speckit.checklist after /speckit.plan in quickstart (#3108) +- fix(workflows): preserve commas inside quoted list-literal elements (#3134) +- ci: pin actions to commit SHAs and add shellcheck (#3126) +- chore: release 0.11.7, begin 0.11.8.dev0 development (#3154) + +## [0.11.7] - 2026-06-24 + +### Changed + +- feat(extensions): verify catalog archive sha256 before install (#3080) +- fix(workflows): validate requires keys and reject phantom permissions gate (#3079) +- fix(scripts): use case-sensitive match for acronym retention in PS branch names (#3130) +- feat(integrations): add omp support (#3107) +- fix: render valid TOML when a command body contains backslashes (#3135) +- harden: reject shell=True in run_command (#3132) +- docs: add monorepo guide (#3084) +- fix(scripts): send check-prerequisites.ps1 errors to stderr (#3123) +- fix: write Codex dev skills as files (#2988) +- chore: release 0.11.6, begin 0.11.7.dev0 development (#3121) + ## [0.11.6] - 2026-06-23 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cf5514a0a..7cc6d28f86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,6 +113,16 @@ uv pip install -e ".[test]" > `specify_cli` to this checkout's `src/`. This matches the gotcha documented in > `AGENTS.md` (Common Pitfalls). +#### Shell scripts + +```bash +git ls-files -z -- '*.sh' | xargs -0 shellcheck --severity=error +``` + +The CI `lint.yml` `shellcheck` job currently reports and blocks only +error-severity findings. Warnings such as SC2155 are intentionally outside this +job until a follow-up cleanup tightens the threshold. + ### Manual testing #### Testing setup diff --git a/README.md b/README.md index 15d016ef95..62514e5b0e 100644 --- a/README.md +++ b/README.md @@ -134,13 +134,14 @@ Explore community-contributed resources on the [Spec Kit docs site](https://gith - [Extensions](https://github.github.io/spec-kit/community/extensions.html) β€” commands, hooks, and capabilities - [Presets](https://github.github.io/spec-kit/community/presets.html) β€” template and terminology overrides +- [Bundles](https://github.github.io/spec-kit/community/bundles.html) β€” role and team stacks composed from existing components - [Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) β€” end-to-end SDD scenarios - [Friends](https://github.github.io/spec-kit/community/friends.html) β€” projects that extend or build on Spec Kit > [!NOTE] > Community contributions are independently created and maintained by their respective authors. Review source code before installation and use at your own discretion. -Want to contribute? See the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md) or the [Presets Publishing Guide](presets/PUBLISHING.md). +Want to contribute? See the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md), the [Presets Publishing Guide](presets/PUBLISHING.md), or the [Community Bundles guide](docs/community/bundles.md). ## πŸ€– Supported AI Coding Agent Integrations @@ -262,8 +263,10 @@ built-in). Each source carries an install policy: `install-allowed` sources can be installed from, while `discovery-only` sources are visible in `search`/`info` but refuse installation. Manage the stack with `specify bundle catalog list|add|remove`. -Authors validate and package bundles locally β€” there is no first-class publish; -distribution is hosting the built artifact and adding a catalog entry: +Authors validate and package bundles locally. Distribution is hosting the built +artifact and adding a catalog source; community bundle submissions use the +[Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) +issue template so required component catalogs and install evidence can be reviewed: ```bash specify bundle validate --path ./my-bundle # structural + reference checks @@ -403,7 +406,7 @@ specify init . --force --integration copilot 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, Goose, Mistral Vibe, or ZCode 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 that your selected agent's CLI tool is installed (for integrations that require a CLI), such as Claude Code, Gemini CLI, Qwen Code, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi Coding Agent, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode. If you don't have the required tool installed, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --integration copilot --ignore-agent-tools diff --git a/docs/community/bundles.md b/docs/community/bundles.md new file mode 100644 index 0000000000..101013034d --- /dev/null +++ b/docs/community/bundles.md @@ -0,0 +1,53 @@ +# Community Bundles + +> [!NOTE] +> Community bundles are independently created and maintained by their respective authors. Maintainers only verify that submission metadata is complete and correctly formatted β€” they do **not review, audit, endorse, or support the bundle code or the components it installs**. Review bundle manifests, component catalogs, and source repositories before installation and use at your own discretion. + +Bundles compose existing Spec Kit components β€” extensions, presets, workflows, and steps β€” into a single role or team stack. They are useful when a user should be able to install a tested set of components together instead of following several separate install commands. + +Accepted community bundle entries will be listed here once a community bundle catalog is available. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue. + +## What to Submit + +A bundle submission should include: + +- A public repository with a valid `bundle.yml` manifest. +- A versioned GitHub release with a bundle artifact created by `specify bundle build`. +- Documentation that explains the intended role, installed components, required catalogs, and expected workflow. +- A proposed catalog entry with bundle metadata and component counts. +- Test evidence from a clean Spec Kit project. + +## Component Resolution + +A bundle catalog entry describes where to download the bundle artifact, but the bundle's component references still need to resolve when a user installs it. References can resolve from bundled components, already installed components, or active extension, preset, workflow, and step catalogs. + +If your bundle depends on components that are not available from the default Spec Kit catalogs, include the required catalog URLs in the submission and in your README. Test the full install path from a clean project with those catalogs added before submitting. + +For example: + +```bash +specify preset catalog add https://example.com/presets.json --name example-bundle --install-allowed +specify extension catalog add https://example.com/extensions.json --name example-bundle --install-allowed +curl -L -o example-bundle-1.0.0.zip https://example.com/example-bundle-1.0.0.zip +specify bundle install ./example-bundle-1.0.0.zip + +# Or install by id from an install-allowed bundle catalog. +specify bundle catalog add https://example.com/bundles.json --id example-bundle-catalog --policy install-allowed +specify bundle install example-bundle +``` + +## Review Scope + +Maintainers check that: + +- The submission fields are complete and correctly formatted. +- The release artifact and documentation URLs are reachable. +- The repository contains a `bundle.yml` manifest. +- The submission clearly identifies any required component catalogs. +- The proposed catalog entry uses the expected bundle catalog entry shape. + +Maintainers do not audit the behavior of installed extensions, presets, workflows, steps, or scripts. Users should review those components before installing a community bundle. + +## Updating a Bundle + +To update a submitted bundle, file another [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue with the new version, download URL, changed component list, and updated test evidence. Mention that the issue updates an existing bundle entry. diff --git a/docs/community/extensions.md b/docs/community/extensions.md index b30d796252..6b2df7a5d1 100644 --- a/docs/community/extensions.md +++ b/docs/community/extensions.md @@ -56,6 +56,7 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | +| Golden Demo | Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report β€” complementary to Architecture Guard and CDD | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) | | Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) | | Intake | Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) | | 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) | @@ -117,6 +118,7 @@ The following community-contributed extensions are available in [`catalog.commun | 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 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 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 Roadmap | Capture a durable spec roadmap after the constitution, then review specs against it before and after implementation so spec-specific decisions, outcomes, and constraints are never lost. | `process` | Read+Write | [speckit-roadmap](https://github.com/srobroek/speckit-roadmap) | | 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 Trace | Build a requirement β†’ test traceability matrix from spec.md and the test suite β€” surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) | diff --git a/docs/community/friends.md b/docs/community/friends.md index 31c6318699..9aff166d0f 100644 --- a/docs/community/friends.md +++ b/docs/community/friends.md @@ -7,7 +7,9 @@ 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. +- **[VS Code 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 Assistant](https://www.npmjs.com/package/speckit-assistant)** β€” A visual orchestrator for Spec-Driven Development (SDD). It connects your local specification, planning, and task checklists with AI agents (Claude, Gemini, GitHub Copilot). No global installation required β€” just run it via `npx speckit-assistant`. - **[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. diff --git a/docs/community/overview.md b/docs/community/overview.md index 99804be3c3..000c27bc69 100644 --- a/docs/community/overview.md +++ b/docs/community/overview.md @@ -1,6 +1,6 @@ # Community -The Spec Kit community builds extensions, presets, walkthroughs, and companion projects that expand what you can do with Spec-Driven Development. All community contributions are independently created and maintained by their respective authors. +The Spec Kit community builds extensions, presets, bundles, walkthroughs, and companion projects that expand what you can do with Spec-Driven Development. All community contributions are independently created and maintained by their respective authors. ## Extensions @@ -14,6 +14,12 @@ Presets customize how Spec Kit behaves β€” overriding templates, commands, and t [Browse community presets β†’](presets.md) +## Bundles + +Bundles compose extensions, presets, workflows, and steps into role or team stacks that can be installed together. + +[Browse community bundles β†’](bundles.md) + ## Walkthroughs Step-by-step guides that show Spec-Driven Development in action across different scenarios, languages, and frameworks. diff --git a/docs/community/presets.md b/docs/community/presets.md index 750abc0809..52f923a3ad 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -25,7 +25,7 @@ The following community-contributed presets customize how Spec Kit behaves β€” o | 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 memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | β€” | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) | -| SicarioSpec Core | Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions. | 5 templates | β€” | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) | +| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | β€” | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) | | 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) | diff --git a/docs/guides/evolving-specs.md b/docs/guides/evolving-specs.md index feb2c88706..e2941f08b3 100644 --- a/docs/guides/evolving-specs.md +++ b/docs/guides/evolving-specs.md @@ -26,6 +26,7 @@ through the standard flow: 2. Run `/speckit.plan` to define the implementation approach. 3. Run `/speckit.tasks` to derive the work breakdown. 4. Run `/speckit.implement` and review the resulting code and artifact diffs. +5. Run `/speckit.converge` to verify completeness and generate tasks for remaining gaps. If tasks are appended, repeat `/speckit.implement` and `/speckit.converge` until the feature is fully complete. The previous feature directory remains intact for audit, comparison, or explaining how the project reached its current state. Use clear feature names or @@ -50,6 +51,7 @@ spec: 5. Run `/speckit.analyze` before implementation resumes to catch gaps between the spec, plan, and tasks. 6. Run `/speckit.implement`, then review the code and artifact diffs together. +7. Run `/speckit.converge` to assess completion and append any remaining work to `tasks.md`. If tasks are appended, repeat `/speckit.implement` and `/speckit.converge` until the feature is fully complete. Preserve important implementation rationale before replacing derived artifacts. If a plan or task list contains decisions that still matter, carry them forward diff --git a/docs/installation.md b/docs/installation.md index 3ee2f67b0e..12708dc5d6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,7 +3,7 @@ ## Prerequisites - **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL) -- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev) +- 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), [Pi Coding Agent](https://pi.dev), or [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) - [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation - [Python 3.11+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) _(optional β€” required only when the git extension is enabled)_ @@ -51,6 +51,7 @@ specify init --integration gemini specify init --integration copilot specify init --integration codebuddy specify init --integration pi +specify init --integration omp ``` ### Specify Script Type (Shell vs PowerShell) @@ -93,8 +94,15 @@ This helps verify you are running the official Spec Kit build from GitHub, not a After initialization, you should see the following commands available in your coding agent: - `/speckit.specify` - Create specifications -- `/speckit.plan` - Generate implementation plans +- `/speckit.plan` - Generate implementation plans - `/speckit.tasks` - Break down into actionable tasks +- `/speckit.implement` - Execute implementation tasks +- `/speckit.analyze` - Validate cross-artifact consistency +- `/speckit.clarify` - Identify and resolve ambiguities +- `/speckit.checklist` - Generate quality checklists +- `/speckit.constitution` - Create or update project principles +- `/speckit.converge` - Assess codebase against artifacts and append remaining tasks +- `/speckit.taskstoissues` - Convert tasks to issues Scripts are installed into a variant subdirectory matching the chosen script type: diff --git a/docs/quickstart.md b/docs/quickstart.md index 9479bbd282..d03808da5b 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -13,10 +13,10 @@ This guide will help you get started with Spec-Driven Development using Spec Kit After installing Spec Kit and defining your project constitution, quick experiments can use the lean feature path: `/speckit.specify` -> `/speckit.plan` -> `/speckit.tasks` -> `/speckit.implement`. For production features or any work with meaningful ambiguity, treat `/speckit.clarify`, `/speckit.checklist`, and `/speckit.analyze` as regular quality gates: ```text -/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.checklist -> /speckit.plan -> /speckit.tasks -> /speckit.analyze -> /speckit.implement +/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.plan -> /speckit.checklist -> /speckit.tasks -> /speckit.analyze -> /speckit.implement -> /speckit.converge ``` -Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` to validate requirements quality before planning, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted. +Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` (after `/speckit.plan`) to generate quality checklists that validate requirements completeness, clarity, and consistency, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted. Finally, run `/speckit.converge` after implementation to verify all planned work is complete and generate tasks for any remaining gaps. If `/speckit.converge` appends new tasks, run `/speckit.implement` again (and converge again) until it reports that the feature has converged. ### Step 1: Install Specify @@ -75,12 +75,6 @@ uvx --from git+https://github.com/github/spec-kit.git specify init [!TIP] > **Phased Implementation**: For large projects like Taskify, consider implementing in phases (e.g., Phase 1: Basic project/task structure, Phase 2: Kanban functionality, Phase 3: Comments and assignments). This prevents context saturation and allows for validation at each stage. diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index e25bddff84..059052cd8f 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -69,6 +69,33 @@ Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes. } ``` +### GitHub Enterprise Server (GHES) + +To use a private catalog or extension hosted on a GitHub Enterprise Server +instance, add a `github` entry listing your GHES host(s). The same entry +authenticates both catalog JSON fetches **and** private release-asset +downloads β€” Specify recognizes the listed hosts as GitHub Enterprise and +resolves release downloads through the GHES REST API (`/api/v3`). + +```json +{ + "providers": [ + { + "hosts": ["ghes.example.com", "raw.ghes.example.com", "codeload.ghes.example.com"], + "provider": "github", + "auth": "bearer", + "token_env": "GH_ENTERPRISE_TOKEN" + } + ] +} +``` + +List the **bare** web host (e.g. `ghes.example.com`) β€” release-download URLs +live there. If your instance uses subdomain isolation, also list the `raw.` +and `codeload.` subdomains your catalog/extension URLs use. A +`*.ghes.example.com` wildcard matches subdomains but **not** the bare host, +so always include the bare host explicitly. + ### Azure DevOps (`azure-devops`) | Scheme | Header | Use for | diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 2a7384cf6b..57f3c700b1 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -119,6 +119,12 @@ specify bundle build Produces a single versioned, distributable `.zip` artifact from a bundle directory. The artifact embeds the manifest and can be installed directly with `specify bundle install `. +## Publish a Bundle + +Bundle authors validate and package bundles locally, then host the generated artifact and catalog metadata where users can access it. A bundle catalog entry points at the bundle artifact, but the components declared inside `bundle.yml` still resolve through bundled components, installed components, or active extension, preset, workflow, and step catalogs. + +If your bundle references components from non-default catalogs, document those catalog URLs and test the install path from a clean project with those catalogs added. Community bundle submissions should include that dependency-resolution evidence in the [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue. + ## Manage Catalog Sources Bundles are discovered through a priority-ordered stack of catalog sources (project, user, and built-in scopes). diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 923d0b9b82..90e5ab8747 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -26,6 +26,7 @@ specify extension add | --------------- | -------------------------------------------------------- | | `--dev` | Install from a local directory (for development) | | `--from ` | Install from a custom URL instead of the catalog | +| `--force` | Overwrite if already installed | | `--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. diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index a04e9db1d9..c2fe1d8ea8 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -19,16 +19,17 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [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/` | +| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | | [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` | | [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 | +| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` | | [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). 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` | | +| [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` | | [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` | | @@ -53,6 +54,27 @@ Shows all available integrations, which one is currently installed, and whether 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. +## Search Available Integrations + +```bash +specify integration search [query] +``` + +| Option | Description | +| ---------- | ------------------ | +| `--tag` | Filter by tag | +| `--author` | Filter by author | + +Searches the active catalog stack for integrations matching the query. Without a query, lists all available integrations. Must be run inside a Spec Kit project. + +## Integration Info + +```bash +specify integration info +``` + +Shows catalog details for a single integration, including its description, author, license, tags, source catalog, repository (when available), and whether it is currently active. Must be run inside a Spec Kit project. + ## Install an Integration ```bash @@ -99,6 +121,7 @@ specify integration switch | ------------------------ | ------------------------------------------------------------------------ | | `--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 | +| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) | | `--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 `. @@ -157,7 +180,7 @@ 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 | +| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dottedβ†’hyphenated directory names); when the `agent-context` extension is enabled, also migrates `KIMI.md` to `AGENTS.md` | Example: @@ -165,6 +188,18 @@ Example: specify integration install generic --integration-options="--commands-dir .myagent/cmds" ``` +## Scaffold a New Integration + +```bash +specify integration scaffold +``` + +Creates a minimal built-in integration package and a matching test skeleton in the Spec Kit repository, then prints the next steps for wiring it up. Run this command from the Spec Kit repository root. The `` must be lowercase kebab-case (for example, `my-agent`). + +| Option | Description | +| -------- | ---------------------------------------------------------------- | +| `--type` | Scaffold template to use: `markdown` (default), `skills`, `toml`, or `yaml` | + ## FAQ ### Can I install multiple integrations in the same project? @@ -183,6 +218,7 @@ The currently declared multi-install safe integrations are: | --- | --------- | | `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` | | `claude` | `.claude/skills`, `CLAUDE.md` | +| `cline` | `.clinerules/workflows`, `.clinerules/specify-rules.md` | | `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` | | `codex` | `.agents/skills`, `AGENTS.md` | | `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` | @@ -191,7 +227,6 @@ The currently declared multi-install safe integrations are: | `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` | @@ -199,6 +234,7 @@ The currently declared multi-install safe integrations are: | `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` | | `trae` | `.trae/skills`, `.trae/rules/project_rules.md` | | `windsurf` | `.windsurf/workflows`, `.windsurf/rules/specify-rules.md` | +| `zcode` | `.zcode/skills`, `ZCODE.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`. diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 4a613ffc00..549177c1d6 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -137,9 +137,11 @@ catalogs: ## 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. +Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers. -> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release. +Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into detected agent directories instead of being re-resolved by agents. During preset install, Spec Kit registers command files for the preset being installed; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Agents do not re-resolve the stack each time they run a command. + +By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder. The resolution stack, from highest to lowest precedence: @@ -148,8 +150,6 @@ The resolution stack, from highest to lowest precedence: 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 @@ -215,7 +215,7 @@ Run `specify preset resolve ` to trace the resolution stack and see which ### 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`. +**Disabling** (`specify preset disable`) keeps the preset installed but excludes it from future template and script resolution. Previously registered commands remain available in your AI coding agent until preset removal, so use removal when you need command changes to stop taking effect. Disabling is useful for temporarily testing template/script behavior without a preset, or comparing template/script 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. diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 5f6e90d924..16bbe0893e 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -262,6 +262,7 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta | `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 | +| `init` | Bootstrap a project (like `specify init`) | | `gate` | Pause for human approval before continuing | | `if` | Conditional branching (then/else) | | `switch` | Multi-branch dispatch on an expression | @@ -270,6 +271,8 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta | `fan-out` | Dispatch a step for each item in a list | | `fan-in` | Aggregate results from a fan-out step | +> **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox β€” `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands. + ## Expressions Steps can reference inputs and previous step outputs using `{{ expression }}` syntax: diff --git a/docs/toc.yml b/docs/toc.yml index 711abb3375..1fb55dc83f 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -66,6 +66,8 @@ href: community/extensions.md - name: Presets href: community/presets.md + - name: Bundles + href: community/bundles.md - name: Walkthroughs href: community/walkthroughs.md - name: Friends diff --git a/docs/upgrade.md b/docs/upgrade.md index 026279e340..c28daf396a 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -308,6 +308,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur ls -la .gemini/commands/ # Gemini ls -la .cursor/skills/ # Cursor ls -la .pi/prompts/ # Pi Coding Agent + ls -la .omp/commands/ # Oh My Pi ``` 3. **Check agent-specific setup:** @@ -427,7 +428,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 coding agent 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/`, `.omp/commands/`, etc.). Your AI coding agent reads these command files directlyβ€”no need to run `specify` again. **If your agent isn't recognizing slash commands:** @@ -442,6 +443,9 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s # For Pi ls -la .pi/prompts/ + + # For Oh My Pi + ls -la .omp/commands/ ``` 2. **Restart your IDE/editor completely** (not just reload window) diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index be5b375241..13fd08b79c 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -320,6 +320,7 @@ A: Extensions should be free and open-source. Commercial support/services are al "author": "string (required)", "version": "string (required, semver)", "download_url": "string (required, valid URL)", + "sha256": "string (optional, SHA-256 hex digest of the archive at download_url; verified before install)", "repository": "string (required, valid URL)", "homepage": "string (optional, valid URL)", "documentation": "string (optional, valid URL)", diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md index 091e2b4802..adc13e31e2 100644 --- a/extensions/agent-context/README.md +++ b/extensions/agent-context/README.md @@ -6,15 +6,17 @@ It owns the lifecycle of the managed section delimited by the configurable start ## Why an extension? -Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users: +Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users: -- **Opt out** entirely with `specify extension disable agent-context` β€” Spec Kit will then never create or modify the agent context file. -- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` β€” both the Python layer and the bundled scripts honor the same `context_markers` value. +- **Choose whether to install it at all** β€” `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file. +- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` β€” the bundled scripts honor the `context_markers` value. - **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`. -- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). +- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator β€” `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline). ## Commands +The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline). + | Command | Description | |---------|-------------| | `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. | @@ -40,7 +42,7 @@ context_markers: end: "" ``` -- `context_file` β€” the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. +- `context_file` β€” the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted. - `context_files` β€” optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected. - `context_markers.start` / `.end` β€” the delimiters around the managed section. Edit these to use custom markers. @@ -62,5 +64,4 @@ pip install pyyaml specify extension disable agent-context ``` -When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). -Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out. +When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal β€” the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge β€” including the per-agent default mapping in `agent-context-defaults.json` β€” lives entirely within this extension, so disabling it is a complete opt-out. diff --git a/extensions/agent-context/agent-context-defaults.json b/extensions/agent-context/agent-context-defaults.json new file mode 100644 index 0000000000..120c348acb --- /dev/null +++ b/extensions/agent-context/agent-context-defaults.json @@ -0,0 +1,42 @@ +{ + "_comment": "Default coding agent context file per integration, owned by the agent-context extension. Used to self-seed agent-context-config.yml when it declares no context_file/context_files. Keyed by the Spec Kit integration key recorded in .specify/init-options.json. This mapping is independent of the Specify CLI by design.", + "agents": { + "agy": "AGENTS.md", + "amp": "AGENTS.md", + "auggie": ".augment/rules/specify-rules.md", + "bob": "AGENTS.md", + "claude": "CLAUDE.md", + "cline": ".clinerules/specify-rules.md", + "codebuddy": "CODEBUDDY.md", + "codex": "AGENTS.md", + "copilot": ".github/copilot-instructions.md", + "cursor-agent": ".cursor/rules/specify-rules.mdc", + "devin": "AGENTS.md", + "firebender": ".firebender/rules/specify-rules.mdc", + "forge": "AGENTS.md", + "gemini": "GEMINI.md", + "generic": "AGENTS.md", + "goose": "AGENTS.md", + "hermes": "AGENTS.md", + "iflow": "IFLOW.md", + "junie": ".junie/AGENTS.md", + "kilocode": ".kilocode/rules/specify-rules.md", + "kimi": "AGENTS.md", + "kiro-cli": "AGENTS.md", + "lingma": ".lingma/rules/specify-rules.md", + "omp": "AGENTS.md", + "opencode": "AGENTS.md", + "pi": "AGENTS.md", + "qodercli": "QODER.md", + "qwen": "QWEN.md", + "roo": ".roo/rules/specify-rules.md", + "rovodev": "AGENTS.md", + "shai": "SHAI.md", + "tabnine": "TABNINE.md", + "trae": ".trae/rules/project_rules.md", + "vibe": "AGENTS.md", + "windsurf": ".windsurf/rules/specify-rules.md", + "zcode": "ZCODE.md", + "zed": "AGENTS.md" + } +} diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 9d57b08cf5..c3e5c2020e 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -10,9 +10,9 @@ # # Usage: update-agent-context.sh [plan_path] # -# When `plan_path` is omitted, the script picks the most recently modified -# `specs/*/plan.md` if any exist, otherwise emits the section without a -# concrete plan path. +# When `plan_path` is omitted, the script derives it from `.specify/feature.json` +# (written by /speckit-specify). Falls back to the most recently modified +# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet. set -euo pipefail @@ -59,7 +59,7 @@ case "$(uname -s 2>/dev/null || true)" in esac # Parse extension config once; emit context files as JSON, followed by marker strings. -if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY' +if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" "$PROJECT_ROOT" <<'PY' import json import sys try: @@ -95,24 +95,67 @@ def get_str(obj, *keys): context_files = [] seen_context_files = set() case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin")) +def add_context_file(value): + if not isinstance(value, str): + return + candidate = value.strip() + if not candidate: + return + key = candidate.casefold() if case_insensitive else candidate + if key in seen_context_files: + return + context_files.append(candidate) + seen_context_files.add(key) raw_files = data.get("context_files") if isinstance(raw_files, list): for value in raw_files: - if not isinstance(value, str): - continue - candidate = value.strip() - if not candidate: - continue - key = candidate.casefold() if case_insensitive else candidate - if key in seen_context_files: - continue - context_files.append(candidate) - seen_context_files.add(key) + add_context_file(value) if not context_files: - raw_file = get_str(data, "context_file") - candidate = raw_file.strip() - if candidate: - context_files.append(candidate) + add_context_file(get_str(data, "context_file")) +if not context_files: + # Self-seed: the agent-context extension owns its lifecycle, so when its + # own config declares no target it derives one from the active integration + # recorded in init-options.json, using the extension's OWN bundled mapping + # (agent-context-defaults.json). This is independent of the Specify CLI by + # design β€” nothing here imports specify_cli. + project_root = sys.argv[3] if len(sys.argv) > 3 else "." + integration_key = "" + try: + with open( + f"{project_root}/.specify/init-options.json", "r", encoding="utf-8" + ) as fh: + opts = json.load(fh) + if isinstance(opts, dict): + value = opts.get("integration") or opts.get("ai") or "" + integration_key = value if isinstance(value, str) else "" + except Exception: + integration_key = "" + if integration_key: + defaults_path = ( + f"{project_root}/.specify/extensions/agent-context/" + "agent-context-defaults.json" + ) + mapping = {} + try: + with open(defaults_path, "r", encoding="utf-8") as fh: + loaded = json.load(fh) + agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {} + mapping = agents if isinstance(agents, dict) else {} + except Exception: + print( + "agent-context: unable to read %s; cannot self-seed the context " + "file. Set 'context_file' in the extension config." % defaults_path, + file=sys.stderr, + ) + mapping = {} + add_context_file(mapping.get(integration_key, "") or "") + if not context_files: + print( + "agent-context: no default context file is known for integration " + "'%s'. Set 'context_file' in the extension config to choose one." + % integration_key, + file=sys.stderr, + ) print(json.dumps(context_files)) print(get_str(data, "context_markers", "start")) print(get_str(data, "context_markers", "end")) @@ -202,23 +245,78 @@ unset _cf_parts _seg PLAN_PATH="${1:-}" if [[ -z "$PLAN_PATH" ]]; then - # Pick the most recently modified plan.md one level deep (specs//plan.md). - # Use find + sort by modification time to avoid ls/head fragility with - # spaces in paths or SIGPIPE from pipefail. - _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY' -import sys, os + # Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic. + _feature_json="$PROJECT_ROOT/.specify/feature.json" + if [[ -f "$_feature_json" ]]; then + _feature_dir="$("$_python" - "$_feature_json" <<'PY' +import sys, json +try: + with open(sys.argv[1], encoding="utf-8") as fh: + d = json.load(fh) + val = d.get("feature_directory", "") + print(val if isinstance(val, str) else "") +except Exception: + print("") +PY +)" + # Normalize backslashes (written by PS on Windows) to forward slashes before path ops. + _feature_dir="$(printf '%s' "$_feature_dir" | tr '\\' '/')" + _feature_dir="${_feature_dir%/}" + if [[ -n "$_feature_dir" ]]; then + # feature_directory may be relative or absolute (absolute paths outside PROJECT_ROOT + # are preserved as-is by _persist_feature_json in common.sh). + # Also match drive-qualified paths (C:/...) written by PowerShell on Windows. + if [[ "$_feature_dir" == /* ]] || [[ "$_feature_dir" =~ ^[A-Za-z]:/ ]]; then + _candidate="$_feature_dir/plan.md" + else + _candidate="$PROJECT_ROOT/$_feature_dir/plan.md" + fi + if [[ -f "$_candidate" ]]; then + # Resolve symlinks before comparing so paths like /var/… vs /private/var/… + # (macOS) are treated as equivalent. Mirrors the mtime-fallback approach. + PLAN_PATH="$("$_python" - "$PROJECT_ROOT" "$_candidate" <<'PY' +import sys from pathlib import Path -specs = Path(sys.argv[1]) / "specs" +root = Path(sys.argv[1]).resolve() +cand = Path(sys.argv[2]).resolve() +try: + print(cand.relative_to(root).as_posix()) +except ValueError: + # Outside project root: emit the resolved path in POSIX form. + # as_posix() converts backslashes correctly on native Windows Python. + print(cand.as_posix()) +PY +)" + fi + fi + fi + + # Fall back to mtime only when feature.json is absent or its plan does not exist yet. + # Python emits a project-relative POSIX path directly to avoid bash prefix-strip + # issues with backslash paths on Windows (Git bash / MSYS2). + if [[ -z "$PLAN_PATH" ]]; then + _plan_rel="$("$_python" - "$PROJECT_ROOT" <<'PY' +import sys +from pathlib import Path +root = Path(sys.argv[1]).resolve() +specs = root / "specs" plans = sorted( specs.glob("*/plan.md"), key=lambda p: p.stat().st_mtime, reverse=True, ) -print(plans[0] if plans else "") +if plans: + try: + print(plans[0].relative_to(root).as_posix()) + except ValueError: + print("") +else: + print("") PY )" - if [[ -n "$_plan_abs" ]]; then - PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" + if [[ -n "$_plan_rel" ]]; then + PLAN_PATH="$_plan_rel" + fi fi fi @@ -240,11 +338,58 @@ for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do mkdir -p "$(dirname "$CTX_PATH")" "$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' -import sys, os +import os +import re +import sys + ctx_path, start, end, section_path = sys.argv[1:5] with open(section_path, "r", encoding="utf-8") as fh: section = fh.read().rstrip("\n") + "\n" + +def ensure_mdc_frontmatter(content): + """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. + + Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with + ``alwaysApply: true``. Prepend it when missing, or repair the value while + preserving any existing frontmatter comments/formatting. + """ + 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 = 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" + + if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text): + return content + + 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}" + + if os.path.exists(ctx_path): with open(ctx_path, "r", encoding="utf-8-sig") as fh: content = fh.read() @@ -274,6 +419,8 @@ else: new_content = section new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") +if ctx_path.casefold().endswith(".mdc"): + new_content = ensure_mdc_frontmatter(new_content) with open(ctx_path, "wb") as fh: fh.write(new_content.encode("utf-8")) PY diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index d31fcd64c0..98a55c55fd 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -9,6 +9,10 @@ # .specify/extensions/agent-context/agent-context-config.yml # # Usage: update-agent-context.ps1 [plan_path] +# +# When `plan_path` is omitted, the script derives it from `.specify/feature.json` +# (written by /speckit-specify). Falls back to the most recently modified +# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet. [CmdletBinding()] param( @@ -16,6 +20,56 @@ param( [string]$PlanPath ) +function Add-MdcFrontmatter { + <# + Ensure .mdc content has YAML frontmatter with alwaysApply: true. + + Cursor only auto-loads .mdc rule files that carry frontmatter with + alwaysApply: true. Prepend it when missing, or repair the value while + preserving any existing frontmatter comments/formatting. + #> + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content) + + $leading = '' + $stripped = $Content + $m = [regex]::Match($Content, '^\s*') + if ($m.Success) { + $leading = $m.Value + $stripped = $Content.Substring($m.Length) + } + + if (-not $stripped.StartsWith('---')) { + return "---`nalwaysApply: true`n---`n`n" + $Content + } + + $fm = [regex]::Match($stripped, '^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)', [System.Text.RegularExpressions.RegexOptions]::Singleline) + if (-not $fm.Success) { + return "---`nalwaysApply: true`n---`n`n" + $Content + } + + $opening = $fm.Groups[1].Value + $fmText = $fm.Groups[2].Value + $closing = $fm.Groups[3].Value + $sep = $fm.Groups[4].Value + $rest = $fm.Groups[5].Value + $newline = if ($opening.Contains("`r`n")) { "`r`n" } else { "`n" } + + if ([regex]::IsMatch($fmText, '(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$')) { + return $Content + } + + if ([regex]::IsMatch($fmText, '(?m)^[ \t]*alwaysApply[ \t]*:')) { + $alwaysApplyRegex = [regex]'(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$' + $fmText = $alwaysApplyRegex.Replace($fmText, '${1}alwaysApply: true${2}', 1) + } elseif ($fmText.Trim()) { + $fmText = $fmText + $newline + 'alwaysApply: true' + } else { + $fmText = 'alwaysApply: true' + } + + return "$leading$opening$fmText$closing$sep$rest" +} + function Get-ConfigValue { param( [AllowNull()][object]$Object, @@ -126,14 +180,26 @@ if (-not (Test-Path -LiteralPath $ExtConfig)) { $Options = $null if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { try { - $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop + $Options = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 | ConvertFrom-Yaml -ErrorAction Stop } catch { - # fall through to Python fallback + # fall through to ConvertFrom-Json fallback } } if ($null -eq $Options) { - # ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML. + # ConvertFrom-Yaml unavailable or failed; try ConvertFrom-Json (no external deps, + # works when the config file is valid JSON, which is a subset of YAML). + try { + $raw = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 + $Options = $raw | ConvertFrom-Json -ErrorAction Stop + if (-not (Test-ConfigObject -Object $Options)) { $Options = $null } + } catch { + $Options = $null + } +} + +if ($null -eq $Options) { + # ConvertFrom-Yaml/Json unavailable or failed; fall back to Python+PyYAML. $pythonCmd = $null $pythonCandidates = @() if ($env:SPECKIT_PYTHON) { @@ -234,6 +300,43 @@ foreach ($ContextFile in $ContextFiles) { } } $ContextFiles = $dedupedContextFiles +if ($ContextFiles.Count -eq 0) { + # Self-seed: the agent-context extension owns its lifecycle, so when its + # own config declares no target it derives one from the active integration + # recorded in init-options.json, using the extension's OWN bundled mapping + # (agent-context-defaults.json). Independent of the Specify CLI by design. + $initOptionsPath = Join-Path $ProjectRoot '.specify/init-options.json' + if (Test-Path -LiteralPath $initOptionsPath) { + try { + $initOpts = Get-Content -LiteralPath $initOptionsPath -Raw | ConvertFrom-Json -ErrorAction Stop + $integrationKey = $null + if ($initOpts.PSObject.Properties['integration'] -and $initOpts.integration) { + $integrationKey = [string]$initOpts.integration + } elseif ($initOpts.PSObject.Properties['ai'] -and $initOpts.ai) { + $integrationKey = [string]$initOpts.ai + } + if ($integrationKey) { + $defaultsPath = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-defaults.json' + if (Test-Path -LiteralPath $defaultsPath) { + $defaults = Get-Content -LiteralPath $defaultsPath -Raw | ConvertFrom-Json -ErrorAction Stop + $derived = $null + if ($defaults.PSObject.Properties['agents'] -and $defaults.agents.PSObject.Properties[$integrationKey]) { + $derived = [string]$defaults.agents.PSObject.Properties[$integrationKey].Value + } + if ($derived -and -not [string]::IsNullOrWhiteSpace($derived)) { + $ContextFiles += $derived.Trim() + } else { + Write-Warning ("agent-context: no default context file is known for integration '{0}'; set 'context_file' in the extension config to choose one." -f $integrationKey) + } + } else { + Write-Warning ("agent-context: unable to read {0}; cannot self-seed the context file. Set 'context_file' in the extension config." -f $defaultsPath) + } + } + } catch { + # Non-fatal: fall through to the nothing-to-do guard below. + } + } +} if ($ContextFiles.Count -eq 0) { Write-Warning 'agent-context: context_files/context_file not set in extension config; nothing to do.' exit 0 @@ -280,21 +383,69 @@ if ($cm) { } if (-not $PlanPath) { - # Discover plan.md exactly one level deep (specs//plan.md), - # matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under - # $ErrorActionPreference = 'Stop' don't abort the script. - try { - $specsDir = Join-Path $ProjectRoot 'specs' - $candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue | - ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } | - Where-Object { $_ } | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 - if ($candidate) { - $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + # Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic. + $FeatureJson = Join-Path $ProjectRoot '.specify/feature.json' + if (Test-Path -LiteralPath $FeatureJson) { + try { + $fj = Get-Content -LiteralPath $FeatureJson -Raw -Encoding UTF8 | ConvertFrom-Json + $featureDir = $fj.feature_directory + if ($featureDir -isnot [string] -or -not $featureDir) { + $featureDir = $null + } else { + $featureDir = $featureDir.TrimEnd('\', '/') + } + if ($featureDir) { + # Join-Path on Unix does not treat absolute ChildPath as "wins"; check explicitly. + if ([System.IO.Path]::IsPathRooted($featureDir)) { + $candidatePlan = Join-Path $featureDir 'plan.md' + } else { + $candidatePlan = Join-Path (Join-Path $ProjectRoot $featureDir) 'plan.md' + } + if (Test-Path -LiteralPath $candidatePlan) { + # Resolve ./ .. segments before relativizing (mirrors bash Path.resolve()). + # GetFullPath is available in .NET Framework 4.x (PS 5.1 compatible). + $resolvedPlan = [System.IO.Path]::GetFullPath($candidatePlan) + $resolvedDir = [System.IO.Path]::GetDirectoryName($resolvedPlan) + $normRoot = $ProjectRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + $normDir = $resolvedDir.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + $cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + if ($normDir.StartsWith($normRoot, $cmp)) { + $relDir = $normDir.Substring($normRoot.Length).TrimEnd('\', '/') + $PlanPath = if ($relDir) { $relDir.Replace('\', '/') + '/plan.md' } else { 'plan.md' } + } else { + $PlanPath = $resolvedPlan.Replace('\', '/') + } + } + } + } catch { + # Non-fatal: fall through to mtime heuristic. + } + } + + # Fall back to mtime only when feature.json is absent or its plan does not exist yet. + if (-not $PlanPath) { + try { + $specsDir = Join-Path $ProjectRoot 'specs' + $candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } | + Where-Object { $_ } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($candidate) { + # GetRelativePath is .NET 5+ only; strip prefix manually for PS 5.1 compat. + # Use case-insensitive comparison on Windows only (matches common.ps1 pattern). + $fullPath = $candidate.FullName.Replace('\', '/') + $normRoot = $ProjectRoot.Replace('\', '/').TrimEnd('/') + '/' + $cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal } + if ($fullPath.StartsWith($normRoot, $cmp)) { + $PlanPath = $fullPath.Substring($normRoot.Length) + } else { + $PlanPath = $fullPath + } + } + } catch { + # Non-fatal: continue without a plan path. } - } catch { - # Non-fatal: continue without a plan path. } } @@ -347,6 +498,9 @@ foreach ($ContextFile in $ContextFiles) { } $newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n") + if ($ContextFile -match '\.mdc$') { + $newContent = Add-MdcFrontmatter -Content $newContent + } [System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false))) Write-Host "agent-context: updated $ContextFile" diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index e72b5dc517..2d0a89f6cb 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-23T00:00:00Z", + "updated_at": "2026-06-29T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -772,40 +772,40 @@ "companion": { "name": "SpecKit Companion", "id": "companion", - "description": "Live spec-driven progress for SpecKit Companion β€” lifecycle capture, status, resume, and a turbo pipeline profile.", + "description": "Live spec-driven progress for SpecKit Companion β€” lifecycle capture, status, resume, and composable commands you can customize with hooks and recipes.", "author": "alfredoperez", - "version": "0.3.0", - "download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.3.0/companion-0.3.0.zip", + "version": "0.11.0", + "download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.11.0/companion-0.11.0.zip", "repository": "https://github.com/alfredoperez/speckit-companion", "homepage": "https://github.com/alfredoperez/speckit-companion/tree/main/speckit-extension", - "documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/docs/", + "documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/README.md", "changelog": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/CHANGELOG.md", "license": "MIT", "category": "visibility", "effect": "read-write", "requires": { - "speckit_version": ">=0.8.5", + "speckit_version": ">=0.9.5", "tools": [ { "name": "python3", "required": false } ] }, "provides": { - "commands": 10, + "commands": 13, "hooks": 4 }, "tags": [ - "tracking", - "companion", - "progress", "vscode", - "lifecycle", - "resume" + "progress", + "status", + "resume", + "configurable", + "extensible" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-06-11T00:00:00Z", - "updated_at": "2026-06-11T00:00:00Z" + "updated_at": "2026-06-24T00:00:00Z" }, "conduct": { "name": "Conduct Extension", @@ -1327,6 +1327,39 @@ "created_at": "2026-04-12T15:30:00Z", "updated_at": "2026-04-13T14:39:00Z" }, + "golden-demo": { + "name": "Golden Demo", + "id": "golden-demo", + "description": "Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report β€” complementary to Architecture Guard and CDD.", + "author": "jasstt", + "version": "0.1.1", + "download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.1.1.zip", + "repository": "https://github.com/jasstt/spec-kit-golden-demo", + "homepage": "https://github.com/jasstt/spec-kit-golden-demo", + "documentation": "https://github.com/jasstt/spec-kit-golden-demo", + "license": "MIT", + "category": "docs", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 2 + }, + "tags": [ + "testing", + "drift-detection", + "behavioral-oracle", + "tdd", + "quality" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-06-24T00:00:00Z", + "updated_at": "2026-06-24T00:00:00Z" + }, "harness": { "name": "Research Harness", "id": "harness", @@ -1548,25 +1581,34 @@ "id": "jira-sync", "description": "An idempotent, drift-aware, fail-closed reconcile engine that mirrors spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase).", "author": "Ash Brener", - "version": "0.2.0", - "download_url": "https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/tags/v0.2.0.zip", + "version": "0.4.0", + "download_url": "https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/tags/v0.4.0.zip", "repository": "https://github.com/ashbrener/spec-kit-jira-sync", "homepage": "https://github.com/ashbrener/spec-kit-jira-sync", "documentation": "https://github.com/ashbrener/spec-kit-jira-sync/blob/main/README.md", - "changelog": "https://github.com/ashbrener/spec-kit-jira-sync/releases", + "changelog": "https://github.com/ashbrener/spec-kit-jira-sync/blob/main/CHANGELOG.md", "license": "MIT", + "category": "integration", + "effect": "read-write", "requires": { - "speckit_version": ">=0.1.0" + "speckit_version": ">=0.1.0", + "tools": [ + { "name": "bash", "version": ">=4.4", "required": true }, + { "name": "git", "required": true }, + { "name": "curl", "required": true }, + { "name": "jq", "required": true }, + { "name": "gitleaks", "required": false }, + { "name": "trufflehog", "required": false } + ] }, "provides": { - "commands": 2, + "commands": 4, "hooks": 0 }, "tags": [ "issue-tracking", "jira", "tasks-sync", - "lifecycle-mirror", "reconcile", "drift-aware" ], @@ -1574,7 +1616,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-06-08T00:00:00Z", - "updated_at": "2026-06-08T00:00:00Z" + "updated_at": "2026-06-24T00:00:00Z" }, "learn": { "name": "Learning Extension", @@ -2459,8 +2501,8 @@ "id": "product", "description": "Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs.", "author": "d0whc3r", - "version": "0.8.3", - "download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v0.8.3/product-0.8.3.zip", + "version": "1.0.1", + "download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v1.0.1/product-1.0.1.zip", "repository": "https://github.com/d0whc3r/spec-kit-product", "homepage": "https://d0whc3r.github.io/spec-kit-product/", "documentation": "https://github.com/d0whc3r/spec-kit-product/wiki", @@ -2472,7 +2514,7 @@ "speckit_version": ">=0.2.0" }, "provides": { - "commands": 4, + "commands": 3, "hooks": 3 }, "tags": [ @@ -2496,7 +2538,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-05-26T00:00:00Z", - "updated_at": "2026-06-01T00:00:00Z" + "updated_at": "2026-06-29T00:00:00Z" }, "product-forge": { "name": "Product Forge", @@ -2962,6 +3004,40 @@ "created_at": "2026-04-20T00:00:00Z", "updated_at": "2026-04-20T00:00:00Z" }, + "roadmap": { + "name": "Spec Roadmap", + "id": "roadmap", + "description": "Capture a durable spec roadmap after the constitution, then review specs against it before and after implementation so spec-specific decisions, outcomes, and constraints are never lost.", + "author": "srobroek", + "version": "0.1.0", + "download_url": "https://github.com/srobroek/speckit-roadmap/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/srobroek/speckit-roadmap", + "homepage": "https://github.com/srobroek/speckit-roadmap", + "documentation": "https://github.com/srobroek/speckit-roadmap/blob/main/README.md", + "changelog": "https://github.com/srobroek/speckit-roadmap/blob/main/CHANGELOG.md", + "license": "Apache-2.0", + "category": "process", + "effect": "read-write", + "requires": { + "speckit_version": ">=0.11.6" + }, + "provides": { + "commands": 4, + "hooks": 3 + }, + "tags": [ + "roadmap", + "planning", + "governance", + "review", + "spec-alignment" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-06-24T00:00:00Z", + "updated_at": "2026-06-24T00:00:00Z" + }, "schedule": { "name": "Spec Kit Schedule β€” CP-SAT Agent Orchestrator", "id": "schedule", diff --git a/extensions/git/scripts/bash/initialize-repo.sh b/extensions/git/scripts/bash/initialize-repo.sh index 296e363b94..c10876efc6 100755 --- a/extensions/git/scripts/bash/initialize-repo.sh +++ b/extensions/git/scripts/bash/initialize-repo.sh @@ -51,4 +51,4 @@ _git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_ _git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } _git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } -echo "βœ“ Git repository initialized" >&2 +echo "[OK] Git repository initialized" >&2 diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 index 65358df0ba..597bdf40d2 100644 --- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1 @@ -252,7 +252,10 @@ function Get-BranchName { if ($stopWords -contains $word) { continue } if ($word.Length -ge 3) { $meaningfulWords += $word - } elseif ($Description -match "\b$($word.ToUpper())\b") { + } elseif ($Description -cmatch "\b$($word.ToUpper())\b") { + # Case-sensitive (-cmatch) to mirror the bash twin's `grep -qw -- "${word^^}"`: + # keep a short word only when its UPPERCASE form appears in the original + # (an acronym). -match is case-insensitive and would keep every short word. $meaningfulWords += $word } } @@ -397,8 +400,10 @@ if ($Json) { $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName FEATURE_NUM = $featureNum - HAS_GIT = $hasGit } + # $hasGit is computed for branch-creation logic only; it is intentionally not + # emitted so this output contract matches the bash twin: BRANCH_NAME and + # FEATURE_NUM, plus DRY_RUN (added just below) on dry runs. if ($DryRun) { $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true } @@ -406,7 +411,6 @@ if ($Json) { } else { Write-Output "BRANCH_NAME: $branchName" Write-Output "FEATURE_NUM: $featureNum" - Write-Output "HAS_GIT: $hasGit" if (-not $DryRun) { Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" } diff --git a/integrations/catalog.json b/integrations/catalog.json index 5e6862ec1b..931df0d974 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-22T00:00:00Z", + "updated_at": "2026-06-23T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "claude": { @@ -255,6 +255,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli"] }, + "omp": { + "id": "omp", + "name": "Oh My Pi", + "version": "1.0.0", + "description": "Oh My Pi (omp) 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", diff --git a/presets/PUBLISHING.md b/presets/PUBLISHING.md index 661614e5c0..24abffda54 100644 --- a/presets/PUBLISHING.md +++ b/presets/PUBLISHING.md @@ -19,7 +19,7 @@ Before publishing a preset, ensure you have: 1. **Valid Preset**: A working preset with a valid `preset.yml` manifest 2. **Git Repository**: Preset hosted on GitHub (or other public git hosting) -3. **Documentation**: README.md with description and usage instructions +3. **Documentation**: A preset-scoped README.md that explains how to use **this preset**, including a valid `specify preset add ...` install command (see [Usage README Requirements](#usage-readme-requirements)) 4. **License**: Open source license file (MIT, Apache 2.0, etc.) 5. **Versioning**: Semantic versioning (e.g., 1.0.0) 6. **Testing**: Preset tested on real projects with `specify preset add --dev` @@ -147,6 +147,46 @@ https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0 specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip ``` +### Usage README Requirements + +The catalog `documentation` field must point at a README that explains how to use +**this preset** β€” not a product pitch for a broader framework or a separate CLI. + +The submission workflow **mechanically enforces** that the linked README is a GitHub-hosted +URL whose path ends with `README.md`, resolves to a readable file, and contains at least one +valid `specify preset add ...` command. The remaining items (preferring a preset-scoped README +in monorepos, covering the minimum structure) are expectations a human reviewer checks β€” +follow them so your submission isn't sent back for changes. + +- **Point `documentation` at the preset-scoped README.** In a monorepo where the preset + lives in a subdirectory (e.g. `presets//`), link the README inside that directory + (`presets//README.md`) rather than the repository-root README. The root README is + often a marketing/overview page; the catalog should surface preset usage instead. The key + requirement is that this README is reachable at the `documentation` URL so users can read + it *before* downloading the release artifact β€” it's fine for the same file to also ship + inside the release ZIP. +- **Include a valid Spec Kit CLI install command** *(enforced)*. The linked README must + contain at least one `specify preset add ...` invocation. Preferably use the + catalog-install form whose URL matches your Download URL: + + ```bash + # is the same URL you submit as the catalog Download URL β€” + # either the tag archive or a release asset, e.g.: + specify preset add --from https://github.com///archive/refs/tags/vX.Y.Z.zip + specify preset add --from https://github.com///releases/download/vX.Y.Z/-X.Y.Z.zip + ``` + + `specify preset add ` and `specify preset add --dev ` are also accepted, but the + `--from ` form is the clearest signal that the README documents this exact + preset release. +- **Cover the minimum structure** so a reader can decide whether the preset fits: + - What the preset does / what it provides + - The install command using Spec Kit CLI syntax (above) + - When to use it / when not to use it + +A submission whose linked README lacks a valid `specify preset add ...` command **fails +validation** (workflow check 2d) and will not be added until corrected. + --- ## Submit to Catalog @@ -181,11 +221,14 @@ Edit `presets/catalog.community.json` and add your preset. "presets": { "your-preset": { "name": "Your Preset Name", + "id": "your-preset", "description": "Brief description of what your preset provides", "author": "Your Name", "version": "1.0.0", "download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip", + "sha256": "OPTIONAL: SHA-256 hex digest of the archive above; verified before install", "repository": "https://github.com/your-org/spec-kit-preset-your-preset", + "documentation": "https://github.com/your-org/spec-kit-preset-your-preset/blob/main/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.1.0" @@ -242,7 +285,7 @@ git push origin add-your-preset ### Checklist - [ ] Valid preset.yml manifest -- [ ] README.md with description and usage +- [ ] Usage README with a valid `specify preset add ...` command, linked from `documentation` (preset-scoped README recommended for monorepos) - [ ] LICENSE file included - [ ] GitHub release created - [ ] Preset tested with `specify preset add --dev` @@ -263,7 +306,15 @@ After submission, maintainers will review: 2. **Template quality** β€” templates are useful and well-structured 3. **Command coherence** β€” commands reference sections that exist in templates 4. **Security** β€” no malicious content, safe file operations -5. **Documentation** β€” clear README explaining what the preset does +5. **Documentation** β€” the README linked from `documentation` explains how to use *this* preset and contains a valid `specify preset add ...` command + +> **Reviewer note:** the workflow can mechanically check *structure* (the linked README +> resolves and contains a valid `specify preset add ...` snippet; when that snippet uses the +> `--from ` form, its URL must match the submitted download URL exactly β€” other accepted +> forms like `specify preset add ` don't reference the download URL at all). Whether the +> README genuinely documents *this* preset is partly a content judgment, so a human reviewer +> should still confirm the linked doc isn't just a funnel to a separate product or CLI before +> approving. Once verified, `verified: true` is set and the preset appears in `specify preset search`. diff --git a/presets/catalog.community.json b/presets/catalog.community.json index eb751f0997..901553b288 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-06-22T00:00:00Z", + "updated_at": "2026-06-25T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -567,13 +567,13 @@ "sicario-core": { "name": "SicarioSpec Core", "id": "sicario-core", - "version": "0.4.0", - "description": "Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions.", + "version": "0.5.1", + "description": "Baseline secure-by-default Spec Kit governance profile.", "author": "SicarioSpec Contributors", "repository": "https://github.com/dfirs1car1o/sicario-spec", - "download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.4.0/sicario-core-0.4.0.zip", + "download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-core-0.5.1.zip", "homepage": "https://github.com/dfirs1car1o/sicario-spec", - "documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/README.md", + "documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/presets/sicario-core/README.md", "license": "MIT", "requires": { "speckit_version": ">=0.9.0" @@ -583,14 +583,13 @@ "commands": 0 }, "tags": [ - "security", "governance", "security-ops", "secure-by-default", "evidence" ], "created_at": "2026-06-22T00:00:00Z", - "updated_at": "2026-06-22T00:00:00Z" + "updated_at": "2026-06-25T00:00:00Z" }, "spec2cloud": { "name": "Spec2Cloud", diff --git a/pyproject.toml b/pyproject.toml index 7666c1d2cb..e5e81fadb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.11.7.dev0" +version = "0.12.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." readme = "README.md" requires-python = ">=3.11" @@ -74,3 +74,13 @@ precision = 2 show_missing = true skip_covered = false +[tool.ruff.lint] +# Lock in subprocess security posture: any reintroduction of shell=True +# (or os.system / popen2) must be acknowledged with an explicit `# noqa` +# pointing at the rule, making the deviation visible in review. +extend-select = [ + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S605", # start-process-with-a-shell +] + diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index f56fc26577..8596e764da 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -209,7 +209,13 @@ function Test-FileExists { function Test-DirHasFiles { param([string]$Path, [string]$Description) - if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) { + # A directory counts as non-empty when Get-ChildItem returns any entry + # (files or subdirectories) -- matching the JSON contracts checks in + # check-prerequisites.ps1 / setup-tasks.ps1, and treating a directory whose + # only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as + # non-empty like bash check_dir. Filtering out subdirectories would + # mis-report such a directory as empty. + if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) { Write-Output " [OK] $Description" return $true } else { diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 8627caa6e7..91b36bebdb 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -111,8 +111,11 @@ function Get-BranchName { # Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms) if ($word.Length -ge 3) { $meaningfulWords += $word - } elseif ($Description -match "\b$($word.ToUpper())\b") { - # Keep short words if they appear as uppercase in original (likely acronyms) + } elseif ($Description -cmatch "\b$($word.ToUpper())\b") { + # Keep short words only if they appear as uppercase in original (likely + # acronyms). Use -cmatch so the comparison is case-sensitive, matching the + # bash script's case-sensitive grep; -match would be case-insensitive and + # would keep every short word. $meaningfulWords += $word } } @@ -139,8 +142,10 @@ if ($ShortName) { $branchSuffix = Get-BranchName -Description $featureDesc } -# Warn if -Number and -Timestamp are both specified -if ($Timestamp -and $Number -ne 0) { +# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not +# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's +# `[ -n "$BRANCH_NUMBER" ]` check. +if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) { Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" $Number = 0 } @@ -150,8 +155,10 @@ if ($Timestamp) { $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' $branchName = "$featureNum-$branchSuffix" } else { - # Determine branch number from existing feature directories - if ($Number -eq 0) { + # Determine branch number from existing feature directories. Auto-detect only + # when -Number was not supplied; an explicit value (including 0) is honored, + # matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check. + if (-not $PSBoundParameters.ContainsKey('Number')) { $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 } @@ -204,6 +211,10 @@ if (-not $DryRun) { $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom) } else { + # Match the bash twin (create-new-feature.sh): warn on stderr that no + # spec template was found before creating an empty spec file, so the + # missing-template signal is not silently swallowed on Windows. + [Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file") New-Item -ItemType File -Path $specFile -Force | Out-Null } } diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index e34de0fba8..0ebd591c87 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -40,8 +40,22 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) { $content = [System.IO.File]::ReadAllText($template) $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom) + # Emit the copy status like the bash twin (setup-plan.sh); route to stderr + # in -Json mode so stdout stays pure JSON, matching the sibling messages. + if ($Json) { + [Console]::Error.WriteLine("Copied plan template to $($paths.IMPL_PLAN)") + } else { + Write-Output "Copied plan template to $($paths.IMPL_PLAN)" + } } else { - Write-Warning "Plan template not found" + # Match the bash twin's wording and stream routing (stderr in -Json so + # stdout stays pure JSON, stdout otherwise), consistent with the sibling + # "Copied plan template" message above. + if ($Json) { + [Console]::Error.WriteLine("Warning: Plan template not found") + } else { + Write-Output "Warning: Plan template not found" + } # Create a basic plan file if template doesn't exist New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b2e8defb18..5d5361cc8e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -262,85 +262,9 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = console.print(f" - {f}") # --------------------------------------------------------------------------- -# Agent-context extension config helpers +# Skills directory helpers # --------------------------------------------------------------------------- -_AGENT_CTX_EXT_CONFIG = ( - Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml" -) - - -def _load_agent_context_config(project_root: Path) -> dict[str, Any]: - """Load the agent-context extension config, returning defaults on failure.""" - from .integrations.base import IntegrationBase - - defaults: dict[str, Any] = { - "context_file": "", - "context_files": [], - "context_markers": { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, - }, - } - path = project_root / _AGENT_CTX_EXT_CONFIG - if not path.exists(): - return defaults - try: - raw = yaml.safe_load(path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, yaml.YAMLError): - return defaults - if not isinstance(raw, dict): - return defaults - return raw - - -def _save_agent_context_config( - project_root: Path, config: dict[str, Any] -) -> None: - """Persist *config* to the agent-context extension config file.""" - path = project_root / _AGENT_CTX_EXT_CONFIG - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False), encoding="utf-8") - - -def _update_agent_context_config_file( - project_root: Path, - context_file: str | None, - *, - preserve_markers: bool = True, - preserve_context_files: bool = True, -) -> None: - """Update the agent-context extension config with *context_file*. - - When *preserve_markers* is True (default), any existing - ``context_markers`` values are kept unchanged so user customisations - survive integration changes and reinit. When False, the default - markers are written unconditionally. - - When *preserve_context_files* is True (default), an existing - ``context_files`` list is kept unchanged, including an empty list. This - lets projects opt into updating multiple agent context files while still - preserving the legacy singular ``context_file`` value for compatibility. - """ - from .integrations.base import IntegrationBase - - cfg = _load_agent_context_config(project_root) - cfg["context_file"] = context_file or "" - existing_context_files = cfg.get("context_files") - if preserve_context_files: - cfg["context_files"] = ( - existing_context_files if isinstance(existing_context_files, list) else [] - ) - else: - cfg.pop("context_files", None) - if not preserve_markers or not isinstance(cfg.get("context_markers"), dict): - cfg["context_markers"] = { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, - } - _save_agent_context_config(project_root, cfg) - - def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: """Resolve the agent-specific skills directory. @@ -1128,9 +1052,10 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: raise typer.Exit(1) from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset + from specify_cli.authentication.http import github_provider_hosts _wf_url_extra_headers = None - _resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30) + _resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30, github_hosts=github_provider_hosts()) if _resolved_wf_url: source = _resolved_wf_url _wf_url_extra_headers = {"Accept": "application/octet-stream"} @@ -1234,10 +1159,11 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: try: from specify_cli.authentication.http import open_url as _open_url + from specify_cli.authentication.http import github_provider_hosts from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset _wf_cat_extra_headers = None - _resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30) + _resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30, github_hosts=github_provider_hosts()) if _resolved_workflow_url: workflow_url = _resolved_workflow_url _wf_cat_extra_headers = {"Accept": "application/octet-stream"} diff --git a/src/specify_cli/_console.py b/src/specify_cli/_console.py index 33bd70f77f..8d1216387f 100644 --- a/src/specify_cli/_console.py +++ b/src/specify_cli/_console.py @@ -34,6 +34,10 @@ console = Console(highlight=False) +# Stderr-bound console for error/diagnostic output, so human-facing messages +# never contaminate stdout (which carries machine-readable ``--json`` payloads). +err_console = Console(stderr=True, highlight=False) + class StepTracker: """Track and render hierarchical steps without emojis, similar to Claude Code tree output. Supports live auto-refresh via an attached refresh callback. diff --git a/src/specify_cli/_github_http.py b/src/specify_cli/_github_http.py index d2030b57a8..31f6046395 100644 --- a/src/specify_cli/_github_http.py +++ b/src/specify_cli/_github_http.py @@ -10,6 +10,7 @@ import os import urllib.request +from fnmatch import fnmatch from typing import Callable, Dict, Optional from urllib.parse import quote, unquote, urlparse @@ -56,55 +57,79 @@ def build_github_request(url: str) -> urllib.request.Request: return urllib.request.Request(url, headers=headers) +def _host_matches(hostname: str, patterns: tuple[str, ...]) -> bool: + """Return True when *hostname* matches a pattern (exact or ``*.suffix``).""" + hostname = hostname.lower() + return any(p == hostname or fnmatch(hostname, p) for p in patterns) + + def resolve_github_release_asset_api_url( download_url: str, open_url_fn: Callable, timeout: int = 60, + github_hosts: tuple[str, ...] = (), ) -> Optional[str]: - """Resolve a GitHub browser release URL to its REST API asset URL. - - For private or SSO-protected repositories, browser release download - URLs (``https://github.com///releases/download//``) - redirect to an HTML/SSO page instead of delivering the file. This - helper resolves such a URL to the matching GitHub REST API asset URL - (``https://api.github.com/repos/…/releases/assets/``), which can - then be downloaded with ``Accept: application/octet-stream`` and an - auth token to retrieve the actual file payload. - - If *download_url* is already a REST API asset URL, it is returned - as-is. Non-GitHub URLs and GitHub URLs that are not release-download - URLs return ``None``. If the API lookup fails (e.g. network error or - asset not found), ``None`` is returned so callers can fall back to the - original URL. + """Resolve a GitHub release browser-download URL to its REST API asset URL. + + Works for public ``github.com`` and for GitHub Enterprise Server (GHES) + hosts. A host is treated as GHES when it matches one of *github_hosts* + (exact hostname or ``*.suffix``) β€” supply the hosts the user has trusted + under a ``github`` provider in ``auth.json``. This allowlist is the + security gate: unlisted hosts never receive GHES API treatment, so a + malicious catalog cannot induce an API request to an arbitrary host. + + For a public URL the API base is ``https://api.github.com``; for a GHES + host it is ``{scheme}://{host[:port]}/api/v3``. Returns the API asset URL + (downloadable with ``Accept: application/octet-stream`` + a token), the + input unchanged if it is already an API asset URL, or ``None`` when the + URL is not a resolvable GitHub release download or the lookup fails. Args: download_url: The URL to resolve. open_url_fn: A callable compatible with - ``specify_cli.authentication.http.open_url`` used to make the - authenticated API request. + ``specify_cli.authentication.http.open_url`` used for the + authenticated release-metadata lookup. timeout: Per-request timeout in seconds. - - Returns: - The resolved REST API asset URL, or ``None`` if resolution is not - applicable or fails. + github_hosts: Host patterns to treat as GitHub Enterprise Server. """ import json import urllib.error parsed = urlparse(download_url) + hostname = (parsed.hostname or "").lower() parts = [unquote(part) for part in parsed.path.strip("/").split("/")] - # Already a REST API asset URL β€” use it directly - if ( - parsed.hostname == "api.github.com" - and len(parts) >= 6 - and parts[:1] == ["repos"] - and parts[3:5] == ["releases", "assets"] - ): + is_ghes = ( + bool(hostname) + and hostname not in GITHUB_HOSTS + and _host_matches(hostname, github_hosts) + ) + + def _is_asset_path(segments: list[str]) -> bool: + return ( + len(segments) >= 6 + and segments[:1] == ["repos"] + and segments[3:5] == ["releases", "assets"] + ) + + # Already a REST API asset URL β€” use it directly. Pure passthrough induces + # no new request: the caller fetches this same URL regardless, so it is + # gated on path shape alone rather than the GHES allowlist. The token stays + # independently gated by auth.json in the download helper, and only the + # resolving path below (which issues a tag-lookup request) needs the + # allowlist as its anti-SSRF gate. + if hostname == "api.github.com" and _is_asset_path(parts): + return download_url + if hostname and parts[:2] == ["api", "v3"] and _is_asset_path(parts[2:]): return download_url - # Only handle github.com browser release download URLs - if parsed.hostname != "github.com": + # Determine the REST API base for browser release-download URLs. + if hostname == "github.com": + api_base = "https://api.github.com" + elif is_ghes: + authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}" + api_base = f"{parsed.scheme}://{authority}/api/v3" + else: return None # Expecting ///releases/download// @@ -114,7 +139,7 @@ def resolve_github_release_asset_api_url( owner, repo, tag = parts[0], parts[1], parts[4] asset_name = "/".join(parts[5:]) encoded_tag = quote(tag, safe="") - release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{encoded_tag}" + release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}" try: with open_url_fn(release_url, timeout=timeout) as response: diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index d921e591d9..df0b8ddec1 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -65,14 +65,31 @@ def dump_frontmatter(data: dict[str, Any]) -> str: return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip() -def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None: - """Run a shell command and optionally capture output.""" +def run_command( + cmd: list[str], + check_return: bool = True, + capture: bool = False, + shell: bool = False, +) -> str | None: + """Run a command without invoking a shell and optionally capture output. + + The ``shell`` parameter is kept in the signature so existing keyword + callers (and the re-export from ``specify_cli``) don't raise ``TypeError``, + but only the default ``shell=False`` is honoured. ``shell=True`` is + rejected with ``ValueError`` rather than silently ignored, so the + unsupported mode fails loudly instead of running with a different meaning. + """ + if shell: + raise ValueError( + "run_command() does not support shell=True; pass argv as a list" + ) + try: if capture: - result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell) + result = subprocess.run(cmd, check=check_return, capture_output=True, text=True) return result.stdout.strip() else: - subprocess.run(cmd, check=check_return, shell=shell) + subprocess.run(cmd, check=check_return) return None except subprocess.CalledProcessError as e: if check_return: diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 28dc8037e7..7864260a99 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -236,9 +236,14 @@ def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> s toml_lines.append(f"# Source: {source_id}") toml_lines.append("") - # Keep TOML output valid even when body contains triple-quote delimiters. - # Prefer multiline forms, then fall back to escaped basic string. - if '"""' not in body: + # Keep TOML output valid even when body contains triple-quote delimiters + # or backslashes. Prefer multiline forms, then fall back to escaped basic + # string. A multiline *basic* string ("""...""") processes backslash escape + # sequences, so a body containing a backslash (e.g. a Windows path + # ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would + # produce unparseable TOML β€” route those to the *literal* form ('''...'''), + # which does not process escapes, or to the escaped basic string. + if '"""' not in body and "\\" not in body: toml_lines.append('prompt = """') toml_lines.append(body) toml_lines.append('"""') @@ -428,37 +433,6 @@ def resolve_skill_placeholders( body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) - # Resolve __CONTEXT_FILE__ from the agent-context extension config. - # When disabled, ignore stale context_files but keep the singular - # context_file value so generated commands still point at the agent - # context file managed before the extension was disabled. - from .integrations.base import IntegrationBase - - # Local import: _load_agent_context_config lives in __init__.py which - # imports agents.py, so a top-level import would be circular. - from . import _load_agent_context_config - - ac_cfg = _load_agent_context_config(project_root) - extension_enabled = IntegrationBase._agent_context_extension_enabled( - project_root - ) - if extension_enabled: - context_files = IntegrationBase._resolve_context_file_values( - project_root, - ac_cfg, - legacy_context_file=init_opts.get("context_file"), - ) - else: - context_files = IntegrationBase._resolve_context_file_values( - project_root, - ac_cfg, - legacy_context_file=init_opts.get("context_file"), - include_context_files=False, - validate=False, - ) - context_file = IntegrationBase._format_context_file_values(context_files) - body = body.replace("__CONTEXT_FILE__", context_file) - return CommandRegistrar.rewrite_project_relative_paths(body) def _convert_argument_placeholder( diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py index e8ab8c1241..a2888bcce2 100644 --- a/src/specify_cli/authentication/http.py +++ b/src/specify_cli/authentication/http.py @@ -118,6 +118,20 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll return urllib.request.Request(url, headers=headers) +def github_provider_hosts() -> tuple[str, ...]: + """Return host patterns from every ``github`` provider entry in ``auth.json``. + + Used to classify which hosts are GitHub Enterprise Server instances when + resolving release-asset download URLs. Returns an empty tuple when no + ``auth.json`` exists or it contains no ``github`` entries. + """ + hosts: list[str] = [] + for entry in _load_config(): + if entry.provider == "github": + hosts.extend(entry.hosts) + return tuple(hosts) + + def open_url( url: str, timeout: int = 10, diff --git a/src/specify_cli/catalogs.py b/src/specify_cli/catalogs.py index 8bd3b2dc06..d14e8ec425 100644 --- a/src/specify_cli/catalogs.py +++ b/src/specify_cli/catalogs.py @@ -78,7 +78,10 @@ def _validate_catalog_url(cls, url: str) -> None: f"Catalog URL must use HTTPS (got {parsed.scheme}://). " "HTTP is only allowed for localhost." ) - if not parsed.netloc: + # Check hostname, not netloc: netloc is truthy for host-less URLs like + # "https://:8080" or "https://user@", so the host guarantee this error + # promises would not actually hold. hostname is None in those cases. + if not parsed.hostname: raise cls._error("Catalog URL must be a valid URL with a host.") def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None: diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 185e00acf6..afae9bcf84 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -13,7 +13,7 @@ import typer -from ..._console import console +from ..._console import console, err_console from ...bundler import BundlerError from ...bundler.lib.project import ( active_integration, @@ -41,7 +41,9 @@ def _fail(message: str) -> None: """Print an actionable error to stderr and exit non-zero.""" - console.print(f"[red]Error:[/red] {message}", style=None) + # Use the stderr console so the error never lands on stdout, which under + # ``--json`` carries the machine-readable payload and must stay parseable. + err_console.print(f"[red]Error:[/red] {message}", style=None) raise typer.Exit(code=1) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index fc82334da2..dd815b8c5d 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -18,7 +18,6 @@ SCRIPT_TYPE_CHOICES, ) from .._assets import ( - _locate_bundled_extension, _locate_bundled_preset, _locate_bundled_workflow, get_speckit_version, @@ -171,7 +170,6 @@ def init( from .. import ( _install_shared_infra_or_exit, _print_cli_warning, - _update_agent_context_config_file, ensure_executable_scripts, save_init_options, ) @@ -376,7 +374,6 @@ def init( ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), ("workflow", "Install bundled workflow"), - ("agent-context", "Install agent-context extension"), ("final", "Finalize"), ]: tracker.add(key, label) @@ -507,47 +504,6 @@ def init( init_opts["ai_skills"] = True save_init_options(project_path, init_opts) - # --- agent-context extension (bundled, auto-installed) --- - # Installed after init-options.json is written so that skill - # registration can read ai_skills + integration key. - try: - from ..extensions import ExtensionManager as _ExtMgr - - bundled_ac = _locate_bundled_extension("agent-context") - if bundled_ac: - ac_mgr = _ExtMgr(project_path) - if ac_mgr.registry.is_installed("agent-context"): - tracker.complete("agent-context", "already installed") - else: - ac_mgr.install_from_directory( - bundled_ac, get_speckit_version() - ) - tracker.complete("agent-context", "extension installed") - else: - from ..extensions import REINSTALL_COMMAND as _ac_reinstall - - tracker.error( - "agent-context", - f"bundled extension not found β€” installation may be " - f"incomplete. Run: {_ac_reinstall}", - ) - except Exception as ac_err: - sanitized_ac = str(ac_err).replace("\n", " ").strip() - tracker.error( - "agent-context", - f"extension install failed: {sanitized_ac[:120]}", - ) - - # Write context_file to the agent-context extension config - # AFTER the extension install (which copies the template config - # with an empty context_file). - if resolved_integration.context_file: - _update_agent_context_config_file( - project_path, - resolved_integration.context_file, - preserve_markers=True, - ) - ensure_executable_scripts(project_path, tracker=tracker) if preset: diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 3df917af2e..9271a9fde6 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -31,6 +31,7 @@ from .._utils import dump_frontmatter, relative_extension_path_violation from ..catalogs import CatalogEntry as BaseCatalogEntry from ..catalogs import CatalogStackBase +from ..shared_infra import verify_archive_sha256 _FALLBACK_CORE_COMMAND_NAMES = frozenset( { @@ -2056,12 +2057,18 @@ def _resolve_github_release_asset_api_url( ) -> Optional[str]: """Resolve a GitHub release asset URL to its API asset URL. - Delegates to the shared helper in :mod:`specify_cli._github_http`. + Delegates to the shared helper in :mod:`specify_cli._github_http`, + passing the ``github`` provider hosts from ``auth.json`` so GitHub + Enterprise Server release assets resolve via ``/api/v3``. """ from specify_cli._github_http import resolve_github_release_asset_api_url + from specify_cli.authentication.http import github_provider_hosts return resolve_github_release_asset_api_url( - download_url, self._open_url, timeout=timeout + download_url, + self._open_url, + timeout=timeout, + github_hosts=github_provider_hosts(), ) def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: @@ -2621,6 +2628,10 @@ def download_extension( ) as response: zip_data = response.read() + verify_archive_sha256( + zip_data, ext_info.get("sha256"), extension_id, ExtensionError + ) + zip_path.write_bytes(zip_data) return zip_path diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 3b60b6d52d..6821419b30 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -482,6 +482,7 @@ def extension_add( elif from_url: # Install from URL (ZIP file) + import io import urllib.error console.print(f"Downloading from {safe_url}...") @@ -498,10 +499,33 @@ def extension_add( zip_path = Path(download_file.name) try: - from specify_cli.authentication.http import open_url as _open_url - - with _open_url(from_url, timeout=60) as response: + # Use the catalog's authenticated fetch so configured + # credentials (incl. GitHub Enterprise Server) are applied + # and GHES release-asset URLs resolve via /api/v3 β€” keeping + # --from consistent with catalog-based installs. + dl_catalog = ExtensionCatalog(project_root) + download_url = from_url + extra_headers = None + resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url) + if resolved_url: + download_url = resolved_url + extra_headers = {"Accept": "application/octet-stream"} + + with dl_catalog._open_url( + download_url, timeout=60, extra_headers=extra_headers + ) as response: zip_data = response.read() + + if not zipfile.is_zipfile(io.BytesIO(zip_data)): + console.print( + f"[red]Error:[/red] {safe_url} did not return a ZIP archive " + f"(got {len(zip_data)} bytes). This usually means the request " + f"was not authenticated and a login/HTML page was returned. " + f"Verify the URL is correct and that credentials for its host " + f"are configured in ~/.specify/auth.json." + ) + raise typer.Exit(1) + zip_path.write_bytes(zip_data) # Install from downloaded ZIP diff --git a/src/specify_cli/integration_scaffold.py b/src/specify_cli/integration_scaffold.py index e4c4b83b3d..f0ed210332 100644 --- a/src/specify_cli/integration_scaffold.py +++ b/src/specify_cli/integration_scaffold.py @@ -117,11 +117,6 @@ class {class_name}({template.base_class}): "args": "{template.args}", "extension": "{template.extension}", }} - context_file = "AGENTS.md" - # Default to False so the generated boilerplate passes the registry - # contract out of the box: multi-install-safe integrations must each have a - # distinct context_file, and the placeholder above ("AGENTS.md") collides - # with the existing codex integration. Opt in once you pick a unique one. multi_install_safe = False ''' @@ -155,7 +150,6 @@ def test_metadata(): assert integration.registrar_config["format"] == "{template.registrar_format}" assert integration.registrar_config["args"] == "{template.args}" assert integration.registrar_config["extension"] == "{template.extension}" - assert integration.context_file == "AGENTS.md" assert integration.multi_install_safe is False ''' @@ -274,7 +268,7 @@ def scaffold_integration( next_steps = ( f"Register {class_name} in src/specify_cli/integrations/__init__.py.", - "Review config metadata, install_url, requires_cli, context_file, and multi_install_safe.", + "Review config metadata, install_url, requires_cli, and multi_install_safe.", f"Run pytest tests/integrations/test_integration_{package_name}.py -v.", ) return IntegrationScaffoldResult( diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index fe09468a76..f394f64a20 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -70,6 +70,7 @@ def _register_builtins() -> None: from .kimi import KimiIntegration from .kiro_cli import KiroCliIntegration from .lingma import LingmaIntegration + from .omp import OmpIntegration from .opencode import OpencodeIntegration from .pi import PiIntegration from .qodercli import QodercliIntegration @@ -108,6 +109,7 @@ def _register_builtins() -> None: _register(KimiIntegration()) _register(KiroCliIntegration()) _register(LingmaIntegration()) + _register(OmpIntegration()) _register(OpencodeIntegration()) _register(PiIntegration()) _register(QodercliIntegration()) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index f8a696a866..d1bf051f77 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -103,38 +103,17 @@ def _refresh_init_options_speckit_version(project_root: Path) -> None: def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: - """Clear active integration keys from init-options.json when they match. - - Also clears ``context_file`` from the agent-context extension config so - no stale path is left behind when the integration is uninstalled. - """ + """Clear active integration keys from init-options.json when they match.""" from .. import ( - _AGENT_CTX_EXT_CONFIG, - _update_agent_context_config_file, load_init_options, save_init_options, ) opts = load_init_options(project_root) - has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts) - # Remove legacy fields that older versions may have written. - opts.pop("context_file", None) - opts.pop("context_markers", None) - 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) save_init_options(project_root, opts) - # Clear context_file in the extension config if it already exists. - # Avoid creating the config (and parent dirs) in projects where the - # agent-context extension was never installed. - ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG - if ext_cfg_path.exists(): - _update_agent_context_config_file( - project_root, "", preserve_markers=True, preserve_context_files=False - ) - elif has_legacy_context_keys: - save_init_options(project_root, opts) def _remove_integration_json(project_root: Path) -> None: @@ -274,21 +253,13 @@ def _update_init_options_for_integration( integration: Any, script_type: str | None = None, ) -> None: - """Update init-options.json and the agent-context extension config to - reflect *integration* as the active one. - - ``context_file``, ``context_files``, and ``context_markers`` are stored in the agent-context - extension config (``.specify/extensions/agent-context/agent-context-config.yml``), - not in ``init-options.json``. Existing user-customised markers are - always preserved when the config already exists. Existing ``context_files`` - lists are also preserved so projects can keep multi-agent context anchors - during integration switches. Invalid marker values are - silently ignored at runtime by ``_resolve_context_markers()`` which falls - back to the class-level defaults. + """Update init-options.json to reflect *integration* as the active one. + + Agent context/instruction files are owned entirely by the opt-in + agent-context extension, so this function never touches the extension + or its config. """ from .. import ( - _AGENT_CTX_EXT_CONFIG, - _update_agent_context_config_file, load_init_options, save_init_options, ) @@ -296,9 +267,6 @@ def _update_init_options_for_integration( opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key - # Remove legacy fields if they were written by an older version. - opts.pop("context_file", None) - opts.pop("context_markers", None) opts["speckit_version"] = _get_speckit_version() if script_type: opts["script"] = script_type @@ -307,24 +275,6 @@ def _update_init_options_for_integration( else: opts.pop("ai_skills", None) - # Update the agent-context extension config BEFORE init-options.json - # so a failure here doesn't leave init-options partially updated. - ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG - if ext_cfg_path.exists(): - _update_agent_context_config_file( - project_root, - integration.context_file, - preserve_markers=True, - ) - elif integration.context_file: - # Extension config doesn't exist yet (extension not installed). - # Write defaults so scripts have something to read. - _update_agent_context_config_file( - project_root, - integration.context_file, - preserve_markers=False, - ) - save_init_options(project_root, opts) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 6ed69e1e0e..33f8d17a91 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -42,7 +42,6 @@ class AgyIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @staticmethod def _inject_hook_command_note(content: str) -> str: diff --git a/src/specify_cli/integrations/amp/__init__.py b/src/specify_cli/integrations/amp/__init__.py index 39df0a9bbf..5d9d14250d 100644 --- a/src/specify_cli/integrations/amp/__init__.py +++ b/src/specify_cli/integrations/amp/__init__.py @@ -18,4 +18,3 @@ class AmpIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/auggie/__init__.py b/src/specify_cli/integrations/auggie/__init__.py index 08e20fbc25..e6fd702fa3 100644 --- a/src/specify_cli/integrations/auggie/__init__.py +++ b/src/specify_cli/integrations/auggie/__init__.py @@ -18,5 +18,4 @@ class AuggieIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".augment/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index be3ab7133d..c820fd4eed 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -13,14 +13,13 @@ from __future__ import annotations -import json import os import re import shlex import shutil from abc import ABC from dataclasses import dataclass -from pathlib import Path, PureWindowsPath +from pathlib import Path from typing import TYPE_CHECKING, Any import yaml @@ -91,13 +90,9 @@ class IntegrationBase(ABC): And may optionally set: - * ``context_file`` β€” path (relative to project root) of the agent - context/instructions file (e.g. ``"CLAUDE.md"``) - - Projects may additionally opt into managing multiple context files by - setting ``context_files`` in the agent-context extension config. The - integration class still declares one default ``context_file`` for backwards - compatibility and command-template rendering. + * ``invoke_separator`` β€” slash-command separator (defaults to ``"."``) + * ``multi_install_safe`` β€” declare the integration safe to install + alongside others (defaults to ``False``) """ # -- Must be set by every subclass ------------------------------------ @@ -113,9 +108,6 @@ class IntegrationBase(ABC): # -- Optional --------------------------------------------------------- - context_file: str | None = None - """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" - invoke_separator: str = "." """Separator used in slash-command invocations (``"."`` β†’ ``/speckit.plan``).""" @@ -125,16 +117,11 @@ class IntegrationBase(ABC): 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 + Safe integrations must use a static, unique agent root and command + directory. 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 @@ -533,498 +520,6 @@ 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) - - @staticmethod - def _agent_context_extension_enabled(project_root: Path) -> bool: - """Return whether the bundled ``agent-context`` extension is enabled. - - The extension is the single source of truth for managing coding - agent context/instruction files (e.g. ``CLAUDE.md``, - ``.github/copilot-instructions.md``). - - Returns ``True`` (enabled) when: - - the extension registry does not exist (legacy project, backwards - compatibility), or - - the registry has no ``agent-context`` entry (older project layout - predating the extension), or - - the entry is present and not explicitly disabled. - - Returns ``False`` only when an entry exists with ``enabled: false``. - """ - registry_path = ( - project_root / ".specify" / "extensions" / ".registry" - ) - if not registry_path.exists(): - return True - try: - data = json.loads(registry_path.read_text(encoding="utf-8")) - except (OSError, ValueError, UnicodeError): - return True - if not isinstance(data, dict): - return True - extensions = data.get("extensions") - if not isinstance(extensions, dict): - return True - entry = extensions.get("agent-context") - if not isinstance(entry, dict): - return True - return entry.get("enabled", True) is not False - - @staticmethod - def _context_file_dedupe_key(path: str) -> str: - """Return the comparison key for context file de-duplication.""" - return path.casefold() if os.name == "nt" else path - - def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]: - """Return the (start, end) context markers to use for *project_root*. - - Reads ``context_markers.start`` / ``context_markers.end`` from the - agent-context extension config - (``.specify/extensions/agent-context/agent-context-config.yml``) - when present. Falls back to the class-level constants - ``CONTEXT_MARKER_START`` / ``CONTEXT_MARKER_END`` when the file is - missing, the section is absent, or the values are not non-empty - strings. - """ - from .._console import console # local import to avoid cycles - - start = self.CONTEXT_MARKER_START - end = self.CONTEXT_MARKER_END - config_path = ( - project_root - / ".specify" - / "extensions" - / "agent-context" - / "agent-context-config.yml" - ) - try: - raw = config_path.read_text(encoding="utf-8") - cfg = yaml.safe_load(raw) - except (OSError, UnicodeError, ValueError, yaml.YAMLError): - return start, end - markers = cfg.get("context_markers") if isinstance(cfg, dict) else None - if isinstance(markers, dict): - cm_start = markers.get("start") - cm_end = markers.get("end") - s_valid = isinstance(cm_start, str) and cm_start - e_valid = isinstance(cm_end, str) and cm_end - if not s_valid and cm_start is not None: - console.print( - f"[yellow]agent-context: ignoring invalid context_markers.start " - f"({cm_start!r}), using default[/yellow]" - ) - if not e_valid and cm_end is not None: - console.print( - f"[yellow]agent-context: ignoring invalid context_markers.end " - f"({cm_end!r}), using default[/yellow]" - ) - if s_valid: - start = cm_start # type: ignore[assignment] - if e_valid: - end = cm_end # type: ignore[assignment] - return start, end - - @staticmethod - def _validate_context_file_path(project_root: Path, context_file: str) -> str: - """Return a safe project-relative context file path. - - The agent-context scripts reject paths that can escape the project - root; the Python integration path must apply the same guard before - setup or teardown touches context files. - """ - candidate = context_file.strip() - if not candidate: - raise ValueError("agent-context: context file path must not be empty") - - win_path = PureWindowsPath(candidate) - if Path(candidate).is_absolute() or win_path.drive or win_path.root: - raise ValueError( - "agent-context: context files must be project-relative paths; " - f"got {candidate!r}" - ) - if "\\" in candidate: - raise ValueError( - "agent-context: context files must not contain backslash " - f"separators; got {candidate!r}" - ) - - parts = [part for part in re.split(r"[\\/]+", candidate) if part] - if ".." in parts: - raise ValueError( - "agent-context: context files must not contain '..' path " - f"segments; got {candidate!r}" - ) - - root = project_root.resolve() - target = (root / candidate).resolve(strict=False) - try: - target.relative_to(root) - except ValueError as exc: - raise ValueError( - "agent-context: context file path resolves outside the project " - f"root; got {candidate!r}" - ) from exc - - return candidate - - @classmethod - def _resolve_context_file_values( - cls, - project_root: Path, - cfg: dict[str, Any] | None, - *, - fallback_context_file: Any = None, - legacy_context_file: Any = None, - include_context_files: bool = True, - validate: bool = True, - ) -> list[str]: - """Resolve context file config with shared precedence and de-duplication.""" - files: list[str] = [] - seen: set[str] = set() - - def add_context_file(value: Any) -> None: - if not isinstance(value, str): - return - candidate = value.strip() - if not candidate: - return - if validate: - candidate = cls._validate_context_file_path(project_root, candidate) - key = cls._context_file_dedupe_key(candidate) - if key in seen: - return - files.append(candidate) - seen.add(key) - - if isinstance(cfg, dict) and include_context_files: - configured = cfg.get("context_files") - if isinstance(configured, list): - for value in configured: - add_context_file(value) - if files: - return files - - if isinstance(cfg, dict): - add_context_file(cfg.get("context_file")) - if files: - return files - - add_context_file(fallback_context_file) - if files: - return files - - add_context_file(legacy_context_file) - return files - - @staticmethod - def _format_context_file_values(context_files: list[str]) -> str: - """Return context file targets as the template display string.""" - return ", ".join(context_files) - - def _resolve_context_files(self, project_root: Path) -> list[str]: - """Return project-relative context files managed for *project_root*. - - ``context_files`` in the agent-context extension config, when present - and non-empty, takes precedence over the config's singular - ``context_file``. The integration class default is used only when the - extension config has no context file target. - Raises ``ValueError`` when a configured path can escape the project - root. - """ - config_path = ( - project_root - / ".specify" - / "extensions" - / "agent-context" - / "agent-context-config.yml" - ) - try: - raw = config_path.read_text(encoding="utf-8") - cfg = yaml.safe_load(raw) - except (OSError, UnicodeError, ValueError, yaml.YAMLError): - cfg = None - return self._resolve_context_file_values( - project_root, - cfg, - fallback_context_file=self.context_file, - ) - - def _context_file_display(self, project_root: Path) -> str: - """Return human-readable context file target(s) for templates.""" - if not self._agent_context_extension_enabled(project_root): - from .. import _load_agent_context_config - - context_files = self._resolve_context_file_values( - project_root, - _load_agent_context_config(project_root), - fallback_context_file=self.context_file, - include_context_files=False, - validate=False, - ) - return context_files[0] if context_files else "" - return self._format_context_file_values( - self._resolve_context_files(project_root) - ) - - @staticmethod - def _upsert_context_file( - ctx_path: Path, - section: str, - marker_start: str, - marker_end: str, - ) -> None: - """Create or update one managed context section.""" - if ctx_path.exists(): - content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(marker_start) - end_idx = content.find( - 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(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(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 = IntegrationBase._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 = IntegrationBase._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")) - - 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 the configured - start/end markers (default ```` / - ````) is replaced, or appended when no markers - are found. Markers are read from the agent-context extension config - (``.specify/extensions/agent-context/agent-context-config.yml``) - when present, falling back to the class-level constants. - - Returns the path to the first context file, or ``None`` when no context - files are configured or the ``agent-context`` extension is - disabled. - """ - if not self._agent_context_extension_enabled(project_root): - return None - - context_files = self._resolve_context_files(project_root) - if not context_files: - return None - - from .._console import console # local import to avoid cycles - - console.print( - "[yellow]Deprecation:[/yellow] Inline agent-context updates during " - "integration setup will be disabled in v0.12.0. Context file " - "management has moved to the bundled [bold]agent-context[/bold] " - "extension. Run [cyan]specify extension disable agent-context[/cyan] " - "to opt out early.", - highlight=False, - ) - - marker_start, marker_end = self._resolve_context_markers(project_root) - - section = ( - f"{marker_start}\n" - f"{self._build_context_section(plan_path)}\n" - f"{marker_end}\n" - ) - - first_path: Path | None = None - for context_file in context_files: - ctx_path = project_root / context_file - self._upsert_context_file(ctx_path, section, marker_start, marker_end) - if first_path is None: - first_path = ctx_path - return first_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. - Markers are read from the agent-context extension config - (``.specify/extensions/agent-context/agent-context-config.yml``) - when present, falling back to the class-level constants. - """ - if not self._agent_context_extension_enabled(project_root): - return False - - context_files = self._resolve_context_files(project_root) - if not context_files: - return False - - marker_start, marker_end = self._resolve_context_markers(project_root) - removed_any = False - - for context_file in context_files: - ctx_path = project_root / context_file - if not ctx_path.exists(): - continue - - content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(marker_start) - end_idx = content.find( - 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: - continue - - removal_start = start_idx - removal_end = end_idx + len(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() - removed_any = True - continue - - if not normalized.strip(): - ctx_path.unlink() - else: - ctx_path.write_bytes(normalized.encode("utf-8")) - removed_any = True - - return removed_any - @staticmethod def resolve_command_refs(content: str, separator: str = ".") -> str: """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. @@ -1049,7 +544,6 @@ def process_template( 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. @@ -1060,9 +554,8 @@ def process_template( 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 + 6. Rewrite paths: ``scripts/`` β†’ ``.specify/scripts/`` etc. + 7. Replace ``__SPECKIT_COMMAND___`` with invocation strings """ # 1. Extract script command from frontmatter script_command = "" @@ -1122,10 +615,7 @@ def process_template( # 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 + # 6. 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 @@ -1180,8 +670,6 @@ 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 @@ -1196,11 +684,9 @@ 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 ------------------------------- @@ -1234,12 +720,11 @@ def uninstall( class MarkdownIntegration(IntegrationBase): """Concrete base for integrations that use standard Markdown commands. - Subclasses only need to set ``key``, ``config``, ``registrar_config`` - (and optionally ``context_file``). Everything else is inherited. + Subclasses only need to set ``key``, ``config``, ``registrar_config``. + Everything else is inherited. ``setup()`` processes command templates (replacing ``{SCRIPT}``, - ``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the - managed context section into the agent context file. + ``{ARGS}``, ``__AGENT__``, rewriting paths). """ def build_exec_args( @@ -1294,13 +779,11 @@ def setup( else "$ARGUMENTS" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -1308,8 +791,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1323,8 +804,7 @@ class TomlIntegration(IntegrationBase): """Concrete base for integrations that use TOML command format. Mirrors ``MarkdownIntegration`` closely: subclasses only need to set - ``key``, ``config``, ``registrar_config`` (and optionally - ``context_file``). Everything else is inherited. + ``key``, ``config``, ``registrar_config``. Everything else is inherited. ``setup()`` processes command templates through the same placeholder pipeline as ``MarkdownIntegration``, then converts the result to @@ -1500,14 +980,12 @@ def setup( else "{{args}}" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) @@ -1517,8 +995,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1532,8 +1008,7 @@ 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. + ``key``, ``config``, ``registrar_config``. Everything else is inherited. ``setup()`` processes command templates through the same placeholder pipeline as ``MarkdownIntegration``, then converts the result to @@ -1696,7 +1171,6 @@ def setup( else "{{args}}" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -1712,7 +1186,6 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) _, body = self._split_frontmatter(processed) yaml_content = self._render_yaml( @@ -1724,8 +1197,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1741,8 +1212,8 @@ class SkillsIntegration(IntegrationBase): Skills use the ``speckit-/SKILL.md`` directory layout following the `agentskills.io `_ spec. - Subclasses set ``key``, ``config``, ``registrar_config`` (and - optionally ``context_file``) like any integration. They may also + Subclasses set ``key``, ``config``, ``registrar_config`` like any + integration. They may also override ``options()`` to declare additional CLI flags (e.g. ``--skills``, ``--migrate-legacy``). @@ -1887,7 +1358,6 @@ def setup( else "$ARGUMENTS" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -1911,7 +1381,6 @@ def setup( # Process body through the standard template pipeline processed_body = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter β€” we rebuild it for skills. @@ -1958,7 +1427,5 @@ def _quote(v: str) -> str: ) created.append(dst) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index 78f2df0379..b953151bd2 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -18,4 +18,3 @@ class BobIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 0df388172d..923a77607a 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -22,13 +22,17 @@ } # Per-command frontmatter overrides for skills that should run in a forked -# subagent context. Read-only analysis commands are good candidates: the -# heavy reads (spec/plan/tasks artefacts) collapse to a short summary, -# so isolating them keeps the main conversation context clean. -# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent -FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = { - "analyze": {"context": "fork", "agent": "general-purpose"}, -} +# subagent context. See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent +# +# This is intentionally empty. ``analyze`` was previously forked (added in +# #2511) on the assumption that its heavy reads collapse to a short summary, +# but in practice ``/speckit-analyze`` returns a 300-500 line report that is +# injected back into the main conversation. In long sessions each subsequent +# fork inherits that growing context, compounding overhead until the chat +# freezes (#3185). Until a command genuinely returns a compact result, no +# command opts into ``context: fork``. The injection mechanism below stays in +# place so a future command can be added here when that holds true. +FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {} class ClaudeIntegration(SkillsIntegration): @@ -48,7 +52,6 @@ class ClaudeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "CLAUDE.md" multi_install_safe = True @staticmethod diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index c269a16042..ab839b9b56 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -70,7 +70,6 @@ class ClineIntegration(MarkdownIntegration): "format_name": format_cline_command_name, "invoke_separator": "-", } - context_file = ".clinerules/specify-rules.md" invoke_separator = "-" multi_install_safe = True diff --git a/src/specify_cli/integrations/codebuddy/__init__.py b/src/specify_cli/integrations/codebuddy/__init__.py index 980ac7fed7..1487096905 100644 --- a/src/specify_cli/integrations/codebuddy/__init__.py +++ b/src/specify_cli/integrations/codebuddy/__init__.py @@ -9,7 +9,7 @@ class CodebuddyIntegration(MarkdownIntegration): "name": "CodeBuddy", "folder": ".codebuddy/", "commands_subdir": "commands", - "install_url": "https://www.codebuddy.ai/cli", + "install_url": "https://www.codebuddy.cn/docs/cli/installation", "requires_cli": True, } registrar_config = { @@ -18,5 +18,4 @@ class CodebuddyIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "CODEBUDDY.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 4dd79da493..7d1ff86e27 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -26,7 +26,6 @@ class CodexIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" dev_no_symlink = True multi_install_safe = True diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 2659b3f252..5cc34d2b1d 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -4,7 +4,6 @@ - Commands use ``.agent.md`` extension (not ``.md``) - Each command gets a companion ``.prompt.md`` file in ``.github/prompts/`` - Installs ``.vscode/settings.json`` with prompt file recommendations -- Context file lives at ``.github/copilot-instructions.md`` When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds commands as ``speckit-/SKILL.md`` directories under ``.github/skills/`` @@ -79,7 +78,6 @@ class _CopilotSkillsHelper(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".github/copilot-instructions.md" class CopilotIntegration(IntegrationBase): @@ -108,7 +106,6 @@ class CopilotIntegration(IntegrationBase): "args": "$ARGUMENTS", "extension": ".agent.md", } - context_file = ".github/copilot-instructions.md" # Mutable flag set by setup() β€” indicates the active scaffolding mode. _skills_mode: bool = False @@ -354,14 +351,12 @@ def _setup_default( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") - context_file_display = self._context_file_display(project_root) # 1. Process and write command files as .agent.md for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -396,8 +391,6 @@ def _setup_default( self.record_file_in_manifest(dst_settings, project_root, manifest) created.append(dst_settings) - # 4. Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index b83ee42e54..2c328b2fda 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -36,7 +36,6 @@ class CursorAgentIntegration(SkillsIntegration): "extension": "/SKILL.md", } - context_file = ".cursor/rules/specify-rules.mdc" multi_install_safe = True def build_exec_args( diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index b3b21b8526..18c1fc8d6d 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -30,7 +30,6 @@ class DevinIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" def build_exec_args( self, diff --git a/src/specify_cli/integrations/firebender/__init__.py b/src/specify_cli/integrations/firebender/__init__.py index b49140b1f8..eb0cec02d5 100644 --- a/src/specify_cli/integrations/firebender/__init__.py +++ b/src/specify_cli/integrations/firebender/__init__.py @@ -3,8 +3,8 @@ Firebender (https://firebender.com/) is an AI coding agent for Android Studio and IntelliJ. It reads project-local custom slash commands from ``.firebender/commands/*.mdc`` and project rules from ``.firebender/rules/*.mdc``, -so Spec Kit installs its command templates as ``.mdc`` command files and writes -the managed context section into a ``.firebender/rules/`` rule file. +so Spec Kit installs its command templates as ``.mdc`` command files. The managed +context section (when used) is owned by the ``agent-context`` extension. """ from ..base import MarkdownIntegration @@ -25,7 +25,6 @@ class FirebenderIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".mdc", } - context_file = ".firebender/rules/specify-rules.mdc" multi_install_safe = True def command_filename(self, template_name: str) -> str: diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index d1cd7a49a8..8c21353fec 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -89,7 +89,6 @@ class ForgeIntegration(MarkdownIntegration): "format_name": format_forge_command_name, # Custom name formatter "invoke_separator": "-", } - context_file = "AGENTS.md" invoke_separator = "-" def setup( @@ -128,14 +127,12 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "{{parameters}}") created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") # Process template with standard MarkdownIntegration logic processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) @@ -152,8 +149,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py index 7c6fe159c7..9a459862af 100644 --- a/src/specify_cli/integrations/gemini/__init__.py +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -18,5 +18,4 @@ class GeminiIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "GEMINI.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index 3d6dd19d44..d874273559 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -31,7 +31,6 @@ class GenericIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -119,13 +118,11 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = "$ARGUMENTS" created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -133,7 +130,5 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py index 0fc4d9d57a..0af569073e 100644 --- a/src/specify_cli/integrations/goose/__init__.py +++ b/src/specify_cli/integrations/goose/__init__.py @@ -1,4 +1,4 @@ -"""Goose integration β€” Block's open source AI agent.""" +"""Goose integration β€” open source AI agent (Agentic AI Foundation).""" from ..base import YamlIntegration @@ -9,7 +9,7 @@ class GooseIntegration(YamlIntegration): "name": "Goose", "folder": ".goose/", "commands_subdir": "recipes", - "install_url": "https://block.github.io/goose/docs/getting-started/installation", + "install_url": "https://goose-docs.ai/docs/getting-started/installation", "requires_cli": True, } registrar_config = { @@ -18,4 +18,3 @@ class GooseIntegration(YamlIntegration): "args": "{{args}}", "extension": ".yaml", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index 1d475c72e2..e094dcfcfe 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -50,7 +50,6 @@ class HermesIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" # -- Helpers ----------------------------------------------------------- @@ -114,7 +113,6 @@ def setup( global_skills_dir.mkdir(parents=True, exist_ok=True) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -141,7 +139,6 @@ def setup( self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter β€” we rebuild it for skills. @@ -183,8 +180,6 @@ def _quote(v: str) -> str: skill_file.write_bytes(normalized.encode("utf-8")) created.append(skill_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) # Create project-local marker directory so extension commands # (e.g. git) can detect Hermes as an active integration. @@ -204,8 +199,7 @@ def teardown( ) -> tuple[list[Path], list[Path]]: """Uninstall integration files including global Hermes skills. - Removes the managed context section from AGENTS.md, removes the - project-local marker directory (if empty), delegates to + Removes the project-local marker directory (if empty), delegates to ``manifest.uninstall()`` for project-local tracked files, and removes all ``speckit-*`` skills under ``~/.hermes/skills/``. @@ -213,8 +207,6 @@ def teardown( standard integration behaviour where all files created by the integration are removed on ``specify integration uninstall``. """ - # Remove managed context section from AGENTS.md - self.remove_context_section(project_root) # Delegate to manifest for project-local tracked files (scripts, # templates, context entries tracked in the manifest). diff --git a/src/specify_cli/integrations/iflow/__init__.py b/src/specify_cli/integrations/iflow/__init__.py index 65d4d21c63..c6b5447bb1 100644 --- a/src/specify_cli/integrations/iflow/__init__.py +++ b/src/specify_cli/integrations/iflow/__init__.py @@ -18,5 +18,4 @@ class IflowIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "IFLOW.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 98d0494a8a..e1e8a9addb 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -18,5 +18,4 @@ class JunieIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".junie/AGENTS.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py index 11674dd9f1..0924843286 100644 --- a/src/specify_cli/integrations/kilocode/__init__.py +++ b/src/specify_cli/integrations/kilocode/__init__.py @@ -18,5 +18,4 @@ class KilocodeIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".kilocode/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 3b257768e2..3320935a03 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -1,11 +1,12 @@ """Kimi Code integration β€” skills-based agent (Moonshot AI). -Kimi uses the ``.kimi/skills/speckit-/SKILL.md`` layout with +Kimi uses the ``.kimi-code/skills/speckit-/SKILL.md`` layout with ``/skill:speckit-`` invocation syntax. -Includes legacy migration logic for projects initialised before Kimi -moved from dotted skill directories (``speckit.xxx``) to hyphenated -(``speckit-xxx``). +Legacy migration covers projects created before Kimi Code CLI moved to +this layout and handles two distinct changes: the directory move from +``.kimi/`` to ``.kimi-code/``, and the dotted-to-hyphenated skill naming +(``speckit.xxx`` β†’ ``speckit-xxx``). """ from __future__ import annotations @@ -24,19 +25,42 @@ class KimiIntegration(SkillsIntegration): key = "kimi" config = { "name": "Kimi Code", - "folder": ".kimi/", + "folder": ".kimi-code/", "commands_subdir": "skills", "install_url": "https://code.kimi.com/", "requires_cli": True, } registrar_config = { - "dir": ".kimi/skills", + "dir": ".kimi-code/skills", "format": "markdown", "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "KIMI.md" - multi_install_safe = True + multi_install_safe = False + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Build Kimi's native skill invocation: ``/skill:speckit-``. + + Kimi Code CLI invokes installed skills with a ``/skill:`` + slash command (e.g. ``/skill:speckit-plan``), not the bare + ``/speckit-`` form produced by the generic skills base + class. Overriding here keeps ``dispatch_command()`` and workflow + command steps aligned with the ``/skill:`` guidance shown at init + time and in rendered hook invocations. + """ + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit.") :] + + invocation = "/skill:speckit-" + stem.replace(".", "-") + if args: + invocation = f"{invocation} {args}" + return invocation + + def post_process_skill_content(self, content: str) -> str: + """Ensure in-skill cross-command references use Kimi's `/skill:` syntax.""" + content = super().post_process_skill_content(content) + return content.replace("/speckit-", "/skill:speckit-") @classmethod def options(cls) -> list[IntegrationOption]: @@ -51,7 +75,10 @@ def options(cls) -> list[IntegrationOption]: "--migrate-legacy", is_flag=True, default=False, - help="Migrate legacy dotted skill dirs (speckit.xxx β†’ speckit-xxx)", + help=( + "Migrate legacy Kimi installations: " + ".kimi/skills/ β†’ .kimi-code/skills/ and speckit.xxx β†’ speckit-xxx" + ), ), ] @@ -62,64 +89,283 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install skills with optional legacy dotted-name migration.""" + """Install skills with optional legacy migration.""" parsed_options = parsed_options or {} - # Run base setup first so hyphenated targets (speckit-*) exist, - # then migrate/clean legacy dotted dirs without risking user content loss. + # Refuse a symlinked destination before any writes occur. base + # setup() only rejects a destination that *escapes* project_root + # after resolve(), so an in-tree symlinked ``.kimi-code`` / + # ``.kimi-code/skills`` (e.g. ``-> .``) would still pass that check + # and misdirect the SKILL.md writes into an unintended in-tree + # location (e.g. ``./skills/``). Reject any symlinked destination + # component up front so this never happens. + new_skills_dir = self.skills_dest(project_root) + if _has_symlinked_component(new_skills_dir, project_root): + raise ValueError( + f"Skills destination {new_skills_dir} contains a symlinked " + f"path component; refusing to install into it." + ) + + # Run base setup first so new-path targets (speckit-*) exist, + # then migrate/clean legacy dirs without risking user content loss. created = super().setup( project_root, manifest, parsed_options=parsed_options, **opts ) if parsed_options.get("migrate_legacy", False): - skills_dir = self.skills_dest(project_root) - if skills_dir.is_dir(): - _migrate_legacy_kimi_dotted_skills(skills_dir) + old_skills_dir = project_root / ".kimi" / "skills" + # Validate both endpoints. base setup() already rejects a + # destination that *escapes* the project root, but an in-tree + # symlinked ``.kimi-code``/``.kimi-code/skills`` (e.g. ``-> .``) + # would still misdirect the move; ``_is_safe_legacy_dir`` rejects + # any symlinked component, giving the destination the same + # protection as the source. + if _is_safe_legacy_dir(old_skills_dir, project_root) and ( + _is_safe_legacy_dir(new_skills_dir, project_root) + ): + _migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir) return created + def teardown( + self, + project_root: Path, + manifest: IntegrationManifest, + *, + force: bool = False, + ) -> tuple[list[Path], list[Path]]: + """Uninstall Kimi skills and remove leftover legacy directories.""" + removed, skipped = super().teardown(project_root, manifest, force=force) + + old_skills_dir = project_root / ".kimi" / "skills" + if _is_safe_legacy_dir(old_skills_dir, project_root): + legacy_dirs = sorted( + [*old_skills_dir.glob("speckit-*"), *old_skills_dir.glob("speckit.*")] + ) + for legacy_dir in legacy_dirs: + if legacy_dir.is_symlink() or not legacy_dir.is_dir(): + continue + if _is_speckit_generated_skill(legacy_dir): + try: + shutil.rmtree(legacy_dir) + removed.append(legacy_dir) + except OSError: + skipped.append(legacy_dir) + + try: + old_skills_dir.rmdir() + except OSError: + pass + + return removed, skipped -def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: - """Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format. + +def _has_symlinked_component(path: Path, project_root: Path) -> bool: + """Return ``True`` when *path* escapes *project_root* or any component is a symlink. + + Walks the components strictly between *project_root* and *path* + (including the final one) and reports whether any of them is a symlink. + Components that do not exist yet are not symlinks, so this safely handles + a not-yet-created destination. *project_root* itself is trusted and never + checked. A *path* outside *project_root* is treated as unsafe. + """ + try: + relative = path.relative_to(project_root) + except ValueError: + return True + current = project_root + for part in relative.parts: + current = current / part + if current.is_symlink(): + return True + return False + + +def _is_safe_legacy_dir(path: Path, project_root: Path) -> bool: + """Return ``True`` when *path* is a real directory safely inside *project_root*. + + Legacy migration and cleanup ``shutil.move()`` and ``shutil.rmtree()`` + directories, so a symlinked ``.kimi``/``.kimi/skills`` (or one reached + through a symlinked parent) must never be followed: doing so could + relocate or delete content living outside the project tree β€” or operate + on an unrelated in-tree directory (e.g. ``.kimi -> .`` makes + ``.kimi/skills`` resolve to ``./skills``). + + Checking only the fully-resolved path is insufficient, because a symlink + pointing elsewhere *inside* the project still resolves to a location under + *project_root*. We therefore reject the path when it is not a directory, + when any component between *project_root* and *path* is a symlink + (including the final component), or when the resolved path escapes the + resolved *project_root*. + """ + if not path.is_dir(): + return False + + # Reject if any path component below project_root is a symlink (or the + # path escapes project_root). We trust project_root itself, so only + # components strictly under it are checked. + if _has_symlinked_component(path, project_root): + return False + + try: + resolved = path.resolve() + root = project_root.resolve() + except OSError: + return False + return resolved == root or root in resolved.parents + + +def _migrate_legacy_kimi_skills_dir( + old_skills_dir: Path, new_skills_dir: Path +) -> tuple[int, int]: + """Migrate skills from the legacy ``.kimi/skills/`` directory to ``.kimi-code/skills/``. + + Handles both hyphenated (``speckit-xxx``) and dotted (``speckit.xxx``) + legacy directory names. If a target already exists, the legacy dir is + only removed when its ``SKILL.md`` is byte-identical and no extra user + files are present. Returns ``(migrated_count, removed_count)``. """ - if not skills_dir.is_dir(): + if not old_skills_dir.is_dir(): return (0, 0) migrated_count = 0 removed_count = 0 - for legacy_dir in sorted(skills_dir.glob("speckit.*")): - if not legacy_dir.is_dir(): + # Process hyphenated dirs first, then dotted dirs. + legacy_dirs = sorted(old_skills_dir.glob("speckit-*")) + sorted( + old_skills_dir.glob("speckit.*") + ) + + for legacy_dir in legacy_dirs: + if legacy_dir.is_symlink() or not legacy_dir.is_dir(): continue - if not (legacy_dir / "SKILL.md").exists(): + legacy_skill = legacy_dir / "SKILL.md" + # Treat a symlinked SKILL.md as invalid: later read_bytes() would + # otherwise follow it and read content from outside the project. + if legacy_skill.is_symlink() or not legacy_skill.is_file(): continue - suffix = legacy_dir.name[len("speckit."):] - if not suffix: + target_name = _legacy_to_target_name(legacy_dir.name) + if not target_name: continue - target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}" + target_dir = new_skills_dir / target_name + + # Skip if the legacy dir is already the target dir (same-directory call). + if legacy_dir.resolve() == target_dir.resolve(): + continue if not target_dir.exists(): + target_dir.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(legacy_dir), str(target_dir)) migrated_count += 1 continue - # Target exists β€” only remove legacy if SKILL.md is identical + # Target exists β€” only remove legacy if SKILL.md is identical. + # Skip when the target dir or its SKILL.md is a symlink (or the dir is + # not a real directory) so the byte comparison never follows a link + # outside the project. (legacy_skill is already guaranteed to be a real + # file by the guard above.) + if target_dir.is_symlink() or not target_dir.is_dir(): + continue target_skill = target_dir / "SKILL.md" - legacy_skill = legacy_dir / "SKILL.md" - if target_skill.is_file(): - try: - if target_skill.read_bytes() == legacy_skill.read_bytes(): - has_extra = any( - child.name != "SKILL.md" for child in legacy_dir.iterdir() - ) - if not has_extra: - shutil.rmtree(legacy_dir) - removed_count += 1 - except OSError: - pass + if target_skill.is_symlink() or not target_skill.is_file(): + continue + try: + if target_skill.read_bytes() == legacy_skill.read_bytes(): + has_extra = any( + child.name != "SKILL.md" for child in legacy_dir.iterdir() + ) + if not has_extra: + shutil.rmtree(legacy_dir) + removed_count += 1 + except OSError: + pass + + # Remove the legacy skills directory if it is now empty. + try: + old_skills_dir.rmdir() + except OSError: + pass return (migrated_count, removed_count) + + +def _legacy_to_target_name(legacy_name: str) -> str: + """Convert a legacy skill directory name to the modern hyphenated form.""" + if legacy_name.startswith("speckit-"): + return legacy_name + if legacy_name.startswith("speckit."): + suffix = legacy_name[len("speckit.") :] + if suffix: + return f"speckit-{suffix.replace('.', '-')}" + return "" + + +def _is_speckit_generated_skill(skill_dir: Path) -> bool: + """Return True when *skill_dir* contains a Speckit-generated SKILL.md. + + Uses the ``metadata.author`` and ``metadata.source`` fields written by + ``SkillsIntegration.setup()`` to avoid deleting user-authored skills. + """ + skill_file = skill_dir / "SKILL.md" + # A symlinked SKILL.md is never treated as Speckit-generated, so teardown + # cleanup never follows it to read frontmatter from outside the project. + if skill_file.is_symlink() or not skill_file.is_file(): + return False + + try: + content = skill_file.read_text(encoding="utf-8") + except OSError: + return False + + if not content.startswith("---"): + return False + + parts = content.split("---", 2) + if len(parts) < 3: + return False + + try: + import yaml + + frontmatter = yaml.safe_load(parts[1]) + except Exception: + return False + + if not isinstance(frontmatter, dict): + return False + + metadata = frontmatter.get("metadata", {}) + if not isinstance(metadata, dict): + return False + + author = metadata.get("author", "") + source = metadata.get("source", "") + return ( + author == "github-spec-kit" + and isinstance(source, str) + and source.startswith("templates/commands/") + ) + + +def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: + """Compatibility shim β€” migrate legacy dotted skill dirs in place. + + .. deprecated:: + Kept for direct callers/tests. New code should call + ``_migrate_legacy_kimi_skills_dir`` directly. + + Delegates to ``_migrate_legacy_kimi_skills_dir`` with *skills_dir* as both + source and destination, so it processes every ``speckit-*`` and + ``speckit.*`` entry under *skills_dir*. Because the two paths are + identical, the same-path short-circuit there skips any directory whose + target resolves to itself; in practice this renames dotted + ``speckit.xxx`` dirs to hyphenated ``speckit-xxx`` in place and never + moves content outside *skills_dir*. + + Returns ``(migrated_count, removed_count)``. + """ + return _migrate_legacy_kimi_skills_dir(skills_dir, skills_dir) diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index 4571b54f90..4c176e5127 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -26,4 +26,3 @@ class KiroCliIntegration(MarkdownIntegration): "args": _KIRO_ARG_FALLBACK, "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/lingma/__init__.py b/src/specify_cli/integrations/lingma/__init__.py index b5cd036033..2cb74b2192 100644 --- a/src/specify_cli/integrations/lingma/__init__.py +++ b/src/specify_cli/integrations/lingma/__init__.py @@ -27,7 +27,6 @@ class LingmaIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".lingma/rules/specify-rules.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/omp/__init__.py b/src/specify_cli/integrations/omp/__init__.py new file mode 100644 index 0000000000..1565832989 --- /dev/null +++ b/src/specify_cli/integrations/omp/__init__.py @@ -0,0 +1,44 @@ +"""Oh My Pi (omp) coding agent integration.""" + +from __future__ import annotations + +from ..base import MarkdownIntegration + + +class OmpIntegration(MarkdownIntegration): + key = "omp" + config = { + "name": "Oh My Pi", + "folder": ".omp/", + "commands_subdir": "commands", + "install_url": "https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent", + "requires_cli": True, + } + registrar_config = { + "dir": ".omp/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + # Diverges from MarkdownIntegration.build_exec_args because OMP's + # CLI parser treats `-p`/`--print` as a boolean (one-shot mode) and + # consumes the prompt as a positional argument β€” see args.ts in + # can1357/oh-my-pi. JSON output is selected via `--mode json`. + if not self.config or not self.config.get("requires_cli"): + return None + args = [self._resolve_executable(), "--print"] + self._apply_extra_args_env_var(args) + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--mode", "json"]) + args.append(prompt) + return args diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index abd97ab2ae..0f734b7f41 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -19,7 +19,6 @@ class OpencodeIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" def build_exec_args( self, diff --git a/src/specify_cli/integrations/pi/__init__.py b/src/specify_cli/integrations/pi/__init__.py index 8a25f326ba..ceff628bdb 100644 --- a/src/specify_cli/integrations/pi/__init__.py +++ b/src/specify_cli/integrations/pi/__init__.py @@ -9,7 +9,7 @@ class PiIntegration(MarkdownIntegration): "name": "Pi Coding Agent", "folder": ".pi/", "commands_subdir": "prompts", - "install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent", + "install_url": "https://www.npmjs.com/package/@earendil-works/pi-coding-agent", "requires_cli": True, } registrar_config = { @@ -18,4 +18,3 @@ class PiIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py index ee2d4b6255..13535203cf 100644 --- a/src/specify_cli/integrations/qodercli/__init__.py +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -18,5 +18,4 @@ class QodercliIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "QODER.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py index 2506a57681..1e8c15bf91 100644 --- a/src/specify_cli/integrations/qwen/__init__.py +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -18,5 +18,4 @@ class QwenIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "QWEN.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/roo/__init__.py b/src/specify_cli/integrations/roo/__init__.py index f610a3cc63..2042c09339 100644 --- a/src/specify_cli/integrations/roo/__init__.py +++ b/src/specify_cli/integrations/roo/__init__.py @@ -18,5 +18,4 @@ class RooIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".roo/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/rovodev/__init__.py b/src/specify_cli/integrations/rovodev/__init__.py index f8879424ac..01aa870c66 100644 --- a/src/specify_cli/integrations/rovodev/__init__.py +++ b/src/specify_cli/integrations/rovodev/__init__.py @@ -39,7 +39,6 @@ class RovodevIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" # -- CLI dispatch ------------------------------------------------------ @@ -228,8 +227,7 @@ def setup( ) -> list[Path]: """Install RovoDev skills, then generate prompt wrappers and manifest. - 1. ``SkillsIntegration.setup()`` generates skill files and - upserts the context section. + 1. ``SkillsIntegration.setup()`` generates the skill files. 2. Generates prompt wrappers and ``prompts.yml`` for each skill created in step 1. """ diff --git a/src/specify_cli/integrations/shai/__init__.py b/src/specify_cli/integrations/shai/__init__.py index 123953da72..8be9596bf1 100644 --- a/src/specify_cli/integrations/shai/__init__.py +++ b/src/specify_cli/integrations/shai/__init__.py @@ -18,5 +18,4 @@ class ShaiIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "SHAI.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py index 0d0076bc56..9edf1e1607 100644 --- a/src/specify_cli/integrations/tabnine/__init__.py +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -18,5 +18,4 @@ class TabnineIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "TABNINE.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py index 4556487d07..03a628d422 100644 --- a/src/specify_cli/integrations/trae/__init__.py +++ b/src/specify_cli/integrations/trae/__init__.py @@ -26,7 +26,6 @@ class TraeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".trae/rules/project_rules.md" multi_install_safe = True @classmethod diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 7922aa8418..136dec8674 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -28,7 +28,6 @@ class VibeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/windsurf/__init__.py b/src/specify_cli/integrations/windsurf/__init__.py index ae5c3301f4..eba38fd1e5 100644 --- a/src/specify_cli/integrations/windsurf/__init__.py +++ b/src/specify_cli/integrations/windsurf/__init__.py @@ -18,5 +18,4 @@ class WindsurfIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".windsurf/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/zcode/__init__.py b/src/specify_cli/integrations/zcode/__init__.py index ea47f31555..46d93c5ca2 100644 --- a/src/specify_cli/integrations/zcode/__init__.py +++ b/src/specify_cli/integrations/zcode/__init__.py @@ -28,7 +28,6 @@ class ZcodeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "ZCODE.md" multi_install_safe = True @classmethod diff --git a/src/specify_cli/integrations/zed/__init__.py b/src/specify_cli/integrations/zed/__init__.py index 882d83cc59..441e9e36f9 100644 --- a/src/specify_cli/integrations/zed/__init__.py +++ b/src/specify_cli/integrations/zed/__init__.py @@ -27,7 +27,6 @@ class ZedIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 66f1bbc5e5..354cc82399 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -31,6 +31,7 @@ from .._init_options import is_ai_skills_enabled from ..integrations.base import IntegrationBase from .._utils import dump_frontmatter +from ..shared_infra import verify_archive_sha256 def _substitute_core_template( @@ -1860,7 +1861,10 @@ def _validate_catalog_url(self, url: str) -> None: f"Catalog URL must use HTTPS (got {parsed.scheme}://). " "HTTP is only allowed for localhost." ) - if not parsed.netloc: + # Check hostname, not netloc: netloc is truthy for host-less URLs like + # "https://:8080" or "https://user@", so the host guarantee this error + # promises would not actually hold. hostname is None in those cases. + if not parsed.hostname: raise PresetValidationError( "Catalog URL must be a valid URL with a host." ) @@ -1891,10 +1895,19 @@ def _resolve_github_release_asset_api_url( download_url: str, timeout: int = 60, ) -> Optional[str]: - """Resolve a GitHub release asset URL to its REST API asset URL.""" + """Resolve a GitHub release asset URL to its REST API asset URL. + + Passes the ``github`` provider hosts from ``auth.json`` so GitHub + Enterprise Server release assets resolve via ``/api/v3``. + """ from specify_cli._github_http import resolve_github_release_asset_api_url + from specify_cli.authentication.http import github_provider_hosts + return resolve_github_release_asset_api_url( - download_url, self._open_url, timeout=timeout + download_url, + self._open_url, + timeout=timeout, + github_hosts=github_provider_hosts(), ) def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: @@ -2505,6 +2518,10 @@ def download_pack( with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response: zip_data = response.read() + verify_archive_sha256( + zip_data, pack_info.get("sha256"), pack_id, PresetError + ) + zip_path.write_bytes(zip_data) return zip_path diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index 682bfe919d..eabfe650dd 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -144,10 +144,13 @@ def _validate_download_redirect(old_url, new_url): zip_path = Path(tmpdir) / "preset.zip" try: from specify_cli.authentication.http import open_url as _open_url + from specify_cli.authentication.http import github_provider_hosts from specify_cli._github_http import resolve_github_release_asset_api_url _preset_extra_headers = None - _resolved_from_url = resolve_github_release_asset_api_url(from_url, _open_url) + _resolved_from_url = resolve_github_release_asset_api_url( + from_url, _open_url, github_hosts=github_provider_hosts() + ) if _resolved_from_url: from_url = _resolved_from_url _preset_extra_headers = {"Accept": "application/octet-stream"} diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index 83fa9d4205..0685b6c9bc 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -2,6 +2,9 @@ from __future__ import annotations +import hashlib +import hmac +import logging import os import re import tempfile @@ -11,6 +14,74 @@ from .integrations.base import IntegrationBase from .integrations.manifest import IntegrationManifest +logger = logging.getLogger(__name__) + +# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal +# characters. Callers lowercase the declared value before matching (see +# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and +# normalized rather than rejected. +_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$") + + +def verify_archive_sha256( + data: bytes, + expected: str | None, + name: str, + error_cls: type[Exception], +) -> None: + """Verify downloaded archive bytes against a catalog-declared SHA-256. + + Catalog entries may pin the expected digest of their release archive in a + ``sha256`` field (optionally prefixed with ``"sha256:"``). When present, the + downloaded bytes must match before they are written to disk and installed, + so a corrupted or tampered archive is rejected even though the transport was + HTTPS. Entries without a declared digest are accepted unchanged, keeping the + check backwards compatible. + + Args: + data: The raw downloaded archive bytes. + expected: The catalog-declared SHA-256 hex digest, or ``None``. + name: The extension/preset id, used in the error message. + error_cls: Exception type to raise on mismatch (e.g. ``ExtensionError``). + + Raises: + error_cls: If ``expected`` is provided and is not a well-formed + SHA-256 hex digest, or does not match ``data``. + """ + # Skip only when no digest is declared at all (``None``). A declared but + # empty/blank value (e.g. ``sha256: ""``) is an authoring error, not an + # opt-out: let it fall through to the format check below so it is rejected + # rather than silently disabling verification. + if expected is None: + logger.debug( + "No sha256 declared for %r; archive integrity was not verified.", + name, + ) + return + # Strip *only* a literal ``sha256:`` algorithm prefix (case-insensitive). + # Any other prefix is part of the value and must not be silently dropped, + # otherwise a malformed or wrong-algorithm digest (e.g. ``md5:...``) would + # be quietly accepted as if it were a valid SHA-256. + raw = str(expected).strip() + if raw[:7].lower() == "sha256:": + raw = raw[7:].strip() + expected_hex = raw.lower() + if not _SHA256_HEX_RE.match(expected_hex): + raise error_cls( + f"Invalid sha256 declared for {name!r}: expected 64 hexadecimal " + f"characters (optionally prefixed with 'sha256:'), got " + f"{expected!r}." + ) + actual_hex = hashlib.sha256(data).hexdigest() + # Constant-time comparison: both sides are fixed-length hex digests, so use + # ``hmac.compare_digest`` to avoid leaking information through timing. + if not hmac.compare_digest(actual_hex, expected_hex): + raise error_cls( + f"Integrity check failed for {name!r}: the catalog declares " + f"sha256 {expected_hex}, but the downloaded archive is " + f"{actual_hex}. The archive may be corrupted or tampered with." + ) + class SymlinkedSharedPathError(ValueError): """Raised when a shared infrastructure path or ancestor is a symlink. diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index f463bc66c1..23b5b0c5c0 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -52,9 +52,18 @@ def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> Non 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", {}) + # Advisory pre-conditions (spec-kit version / integrations a workflow + # expects). Validated by ``validate_workflow`` (recognized keys only; + # see ``_RECOGNIZED_REQUIRES_KEYS``) but NOT enforced at run time β€” they + # are not a security boundary. In particular there is no + # ``requires.permissions`` capability gate: shell steps always run with + # the user's privileges. + # + # Holds the raw parsed value, so before ``validate_workflow`` runs it may + # be a non-mapping (``None`` for a bare ``requires:``, a list for + # ``requires: []``, etc.); typed ``Any`` rather than ``dict[str, Any]`` + # to avoid implying it is always a mapping at this point. + self.requires: Any = data.get("requires", {}) # Inputs self.inputs: dict[str, Any] = data.get("inputs", {}) @@ -87,6 +96,15 @@ def from_string(cls, content: str) -> WorkflowDefinition: # ID format: lowercase alphanumeric with hyphens _ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$") +# Keys accepted under a workflow's ``requires`` block: the advisory +# pre-conditions documented for workflows (``speckit_version`` and +# ``integrations``). This is the *workflow* schema only β€” the bundle manifest's +# ``requires`` (see ``bundler/models/manifest.py``) is a separate schema that +# also carries ``tools``/``mcp``; those are not workflow ``requires`` keys. +# Any other key β€” notably ``permissions`` β€” is rejected by ``validate_workflow`` +# so it is never mistaken for an enforced runtime control. +_RECOGNIZED_REQUIRES_KEYS = frozenset({"speckit_version", "integrations"}) + # 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.""" @@ -177,6 +195,36 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]: f"Input {input_name!r} has invalid default: {exc}" ) + # -- Requires --------------------------------------------------------- + # ``requires`` declares advisory pre-conditions (the spec-kit version and + # integrations a workflow expects). Only a fixed set of keys is recognized; + # reject anything else so authoring typos surface here instead of being + # silently ignored at runtime. In particular ``requires.permissions`` is + # rejected explicitly: it reads like a runtime capability gate, but no such + # gate exists β€” a ``shell`` step always runs with the user's privileges, so + # declaring it would give a false sense of sandboxing. + # + # Mirror ``inputs`` validation: an omitted block defaults to ``{}`` and is + # valid, but any present-but-non-mapping value β€” ``requires:`` (YAML null), + # ``requires: []`` or ``requires: ''`` β€” is an authoring error and must + # surface here rather than be silently ignored at runtime. + if not isinstance(definition.requires, dict): + errors.append("'requires' must be a mapping (or omitted).") + else: + for key in definition.requires: + if key == "permissions": + errors.append( + "'requires.permissions' is not a recognized or " + "enforced capability gate β€” shell steps always run " + "with the user's privileges. Remove it and gate " + "sensitive steps with a 'gate' step instead." + ) + elif key not in _RECOGNIZED_REQUIRES_KEYS: + errors.append( + f"Unknown 'requires' key {key!r}. Recognized keys: " + f"{', '.join(sorted(_RECOGNIZED_REQUIRES_KEYS))}." + ) + # -- Steps ------------------------------------------------------------ if not isinstance(definition.steps, list): errors.append("'steps' must be a list.") @@ -248,6 +296,40 @@ def _validate_steps( f"boolean, got {type(coe).__name__}." ) + # Fan-in: every wait_for id must reference a step declared at or before + # this point. An id not yet seen is either a typo (unknown step) or a + # forward reference (the target runs after this fan-in, so its results + # cannot exist yet) β€” both are wiring errors that previously surfaced as + # a silent empty result + COMPLETED. A step that is declared but only + # conditionally executed (e.g. inside an if/switch branch) is still + # "seen" here, so a legitimately-empty result at runtime stays valid. + if step_type == "fan-in": + wait_for = step_config.get("wait_for") + if isinstance(wait_for, list): + for wid in wait_for: + if not isinstance(wid, str): + # A non-string entry (e.g. YAML `wait_for: [123]`) can + # never match a real step id, so the join is silently + # empty at runtime β€” surface it as a wiring error. + errors.append( + f"Fan-in step {step_id!r}: 'wait_for' entries must " + f"be step-id strings, got {type(wid).__name__} " + f"({wid!r})." + ) + elif wid == step_id: + # The fan-in's own id is already in seen_ids by now, so + # a self-reference would pass the membership check below + # while still producing an empty join at runtime. + errors.append( + f"Fan-in step {step_id!r}: 'wait_for' references " + f"itself; a fan-in cannot wait for its own results." + ) + elif wid not in seen_ids: + errors.append( + f"Fan-in step {step_id!r}: 'wait_for' references " + f"unknown or not-yet-declared step id {wid!r}." + ) + # Recursively validate nested steps for nested_key in ("then", "else", "steps"): nested = step_config.get(nested_key) @@ -962,7 +1044,12 @@ def _coerce_input( value = float(value) if value == int(value): value = int(value) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): + # OverflowError: `int(value)` raises it for an infinite float + # (e.g. a `default: .inf` authoring mistake), which would + # otherwise escape validate_workflow's `except ValueError` and + # break its "return errors, never raise" contract. Surface it as + # the same clean "expected a number" error as NaN does. msg = f"Input {name!r} expected a number, got {value!r}." raise ValueError(msg) from None elif input_type == "boolean": diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index ca10b24d1b..6257930a5a 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -146,6 +146,69 @@ def _build_namespace(context: Any) -> dict[str, Any]: return ns +def _split_top_level_commas(text: str) -> list[str]: + """Split *text* on commas that are not inside quotes or nested brackets. + + Used for list-literal elements so a quoted element containing a comma + (e.g. ``["a, b", "c"]``) is not split mid-string, and nested lists/calls + (e.g. ``[[1, 2], 3]``) are kept intact. + """ + parts: list[str] = [] + buf: list[str] = [] + quote: str | None = None + depth = 0 + for ch in text: + if quote is not None: + buf.append(ch) + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + buf.append(ch) + elif ch in "([{": + depth += 1 + buf.append(ch) + elif ch in ")]}": + depth = max(0, depth - 1) + buf.append(ch) + elif ch == "," and depth == 0: + parts.append("".join(buf)) + buf = [] + else: + buf.append(ch) + parts.append("".join(buf)) + return parts + + +def _find_top_level(text: str, token: str) -> int: + """Return the index of the first occurrence of *token* in *text* that lies + outside any quoted string or nested bracket, or ``-1`` if there is none. + + Used so operator/keyword splitting (``and``/``or``/``in``/comparisons) does + not match a separator that appears *inside* a quoted operand -- e.g. the + ``and`` in ``mode == 'read and write'`` or the ``or`` in ``'approve or reject'``. + """ + quote: str | None = None + depth = 0 + i = 0 + n = len(text) + while i < n: + ch = text[i] + if quote is not None: + if ch == quote: + quote = None + elif ch in ("'", '"'): + quote = ch + elif ch in "([{": + depth += 1 + elif ch in ")]}": + depth = max(0, depth - 1) + elif depth == 0 and text.startswith(token, i): + return i + i += 1 + return -1 + + def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: """Evaluate a simple expression against the namespace. @@ -159,18 +222,21 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: """ 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('"') - ): + # String literal β€” only when the WHOLE expression is one quoted string, + # i.e. the opening quote's matching close is the final character. Checking + # startswith/endswith alone would also grab `'a' == 'b'` and strip it to the + # garbage `a' == 'b`; a genuine single literal short-circuits here so quoted + # strings containing `|` or operator keywords are not mis-parsed downstream. + if expr[:1] in ("'", '"') and expr.find(expr[0], 1) == len(expr) - 1: 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() + # Handle pipe filters. Detect the pipe at the top level only, so a literal + # '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is + # not mistaken for a filter separator β€” mirroring the operator parsing below. + pipe_idx = _find_top_level(expr, "|") + if pipe_idx != -1: + value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace) + filter_expr = expr[pipe_idx + 1:].strip() # `from_json` is strict: it takes no arguments and tolerates no # trailing tokens. Match on the leading filter name and require the @@ -228,29 +294,33 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: ) # 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) + # 'a or b and c' is evaluated as 'a or (b and c)'. Splits are quote/bracket + # aware so a keyword inside a quoted operand (e.g. the 'and' in + # 'read and write') is not mistaken for an operator. + or_idx = _find_top_level(expr, " or ") + if or_idx != -1: + left = _evaluate_simple_expression(expr[:or_idx].strip(), namespace) + right = _evaluate_simple_expression(expr[or_idx + 4:].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) + and_idx = _find_top_level(expr, " and ") + if and_idx != -1: + left = _evaluate_simple_expression(expr[:and_idx].strip(), namespace) + right = _evaluate_simple_expression(expr[and_idx + 5:].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) + # Comparison operators (order matters β€” check multi-char ops first). Split at + # the first top-level occurrence so an operator inside a quoted operand is + # ignored. 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) + op_idx = _find_top_level(expr, op) + if op_idx != -1: + left = _evaluate_simple_expression(expr[:op_idx].strip(), namespace) + right = _evaluate_simple_expression(expr[op_idx + len(op):].strip(), namespace) if op == "==": return left == right if op == "!=": @@ -291,7 +361,10 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: inner = expr[1:-1].strip() if not inner: return [] - items = [_evaluate_simple_expression(i.strip(), namespace) for i in inner.split(",")] + items = [ + _evaluate_simple_expression(i.strip(), namespace) + for i in _split_top_level_commas(inner) + ] return items # Variable reference (dot-path) diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/steps/gate/__init__.py index a2e473244e..e07b6ebd62 100644 --- a/src/specify_cli/workflows/steps/gate/__init__.py +++ b/src/specify_cli/workflows/steps/gate/__init__.py @@ -194,7 +194,14 @@ def validate(self, config: dict[str, Any]) -> list[str]: 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): + # Only inspect option text when every option is a string; otherwise the + # `o.lower()` below would raise AttributeError on a non-string option + # (already reported above) and break validate_workflow's never-raise contract. + if ( + on_reject in ("abort", "retry") + and isinstance(options, list) + and all(isinstance(o, str) for o in options) + ): reject_choices = {"reject", "abort"} if not any(o.lower() in reject_choices for o in options): errors.append( diff --git a/src/specify_cli/workflows/steps/shell/__init__.py b/src/specify_cli/workflows/steps/shell/__init__.py index 8c62e4cfa8..2a65fca444 100644 --- a/src/specify_cli/workflows/steps/shell/__init__.py +++ b/src/specify_cli/workflows/steps/shell/__init__.py @@ -31,7 +31,7 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: # control commands; catalog-installed workflows should be reviewed # before use (see PUBLISHING.md for security guidance). try: - proc = subprocess.run( + proc = subprocess.run( # noqa: S602 -- intentional shell=True (see NOTE above) run_cmd, shell=True, capture_output=True, diff --git a/templates/commands/analyze.md b/templates/commands/analyze.md index 5b521cf2a4..e4ba8f7d81 100644 --- a/templates/commands/analyze.md +++ b/templates/commands/analyze.md @@ -45,6 +45,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Goal. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Goal @@ -228,6 +229,7 @@ After reporting, check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Operating Principles diff --git a/templates/commands/checklist.md b/templates/commands/checklist.md index 2e1b1040af..e202ebb667 100644 --- a/templates/commands/checklist.md +++ b/templates/commands/checklist.md @@ -66,6 +66,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Execution Steps. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Execution Steps @@ -363,4 +364,5 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md index a83d52f026..4948fdcfaf 100644 --- a/templates/commands/clarify.md +++ b/templates/commands/clarify.md @@ -49,6 +49,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -251,6 +252,7 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - **Optional hook** (`optional: true`): ``` ## Extension Hooks diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md index 29ae9a09e2..d003d5c9b2 100644 --- a/templates/commands/constitution.md +++ b/templates/commands/constitution.md @@ -46,6 +46,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -147,4 +148,5 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/converge.md b/templates/commands/converge.md index 3d366e1d30..35cf3736c3 100644 --- a/templates/commands/converge.md +++ b/templates/commands/converge.md @@ -49,6 +49,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Goal. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently @@ -266,5 +267,6 @@ After producing the result, check if `.specify/extensions.yml` exists in the pro Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/templates/commands/implement.md b/templates/commands/implement.md index c416fa7387..eda580d560 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -45,6 +45,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -192,6 +193,7 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - **Optional hook** (`optional: true`): ``` ## Extension Hooks diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 44ab8403ac..e82bd4b303 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -53,6 +53,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -91,6 +92,7 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - **Optional hook** (`optional: true`): ``` ## Extension Hooks @@ -154,14 +156,11 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate - Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites - Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase -4. **Agent context update**: - - Update the plan reference between the `` and `` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path) - -**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file +**Output**: data-model.md, /contracts/*, quickstart.md ## Key rules -- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files +- Use absolute paths for filesystem operations; use project-relative paths for references in documentation - ERROR on gate failures or unresolved clarifications ## Done When diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 4558b922ae..09a584e0ea 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -50,6 +50,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -253,6 +254,7 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - **Optional hook** (`optional: true`): ``` ## Extension Hooks diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index f863e7787f..4d3e45a7c4 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -54,6 +54,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -111,6 +112,7 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - **Optional hook** (`optional: true`): ``` ## Extension Hooks diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index b3093baa03..f1df100010 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -46,6 +46,7 @@ You **MUST** consider the user input before proceeding (if not empty). Wait for the result of the hook command before proceeding to the Outline. ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently ## Outline @@ -100,4 +101,5 @@ Check if `.specify/extensions.yml` exists in the project root. Executing: `/{command}` EXECUTE_COMMAND: {command} ``` + After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook. - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index 018b2bbec1..58a26fae91 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -62,6 +62,21 @@ def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch assert "Spec Kit project" in result.output +def test_fail_writes_error_to_stderr_not_stdout(capsys): + """_fail must write to stderr, not stdout: every bundle command routes errors + through it, and under --json the error would otherwise corrupt the JSON payload + that consumers read from stdout.""" + import typer + + from specify_cli.commands.bundle import _fail + + with pytest.raises(typer.Exit): + _fail("something broke") + captured = capsys.readouterr() + assert "something broke" in captured.err + assert "something broke" not in captured.out + + def test_search_works_without_a_project(tmp_path: Path, monkeypatch): # Discovery commands fall back to the built-in/user catalog stack and must # not require a Spec Kit project (matches README/quickstart examples). diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 3d40aef4ee..2f53854d82 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -233,6 +233,10 @@ def test_initializes_git_repo(self, tmp_path: Path): result = _run_bash("initialize-repo.sh", project) assert result.returncode == 0, result.stderr + # Success marker is the full ASCII "[OK] ..." line (matching the PowerShell + # twin and the sibling auto-commit scripts), not a Unicode checkmark. + assert "[OK] Git repository initialized" in result.stderr, result.stderr + # Verify git repo exists assert (project / ".git").exists() @@ -298,6 +302,42 @@ def test_creates_branch_sequential(self, tmp_path: Path): assert data["BRANCH_NAME"] == "001-user-auth" assert data["FEATURE_NUM"] == "001" + def test_output_omits_has_git_for_parity(self, tmp_path: Path): + """The bash output contract is {BRANCH_NAME, FEATURE_NUM} (+ DRY_RUN) in JSON + and a BRANCH_NAME:/FEATURE_NUM: text block -- no HAS_GIT key/line. This pins + the canonical contract the PowerShell twin must mirror.""" + project = _setup_project(tmp_path) + rj = _run_bash( + "create-new-feature-branch.sh", project, + "--json", "--dry-run", "--short-name", "parity", "Parity feature", + ) + assert rj.returncode == 0, rj.stderr + assert "HAS_GIT" not in json.loads(rj.stdout) + rt = _run_bash( + "create-new-feature-branch.sh", project, + "--dry-run", "--short-name", "parity", "Parity feature", + ) + assert rt.returncode == 0, rt.stderr + assert "HAS_GIT" not in rt.stdout + + def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path): + """A short word is dropped from the derived branch name unless it appears + as an acronym in UPPERCASE in the description (case-sensitive, must match the + PowerShell twin).""" + project = _setup_project(tmp_path) + # lowercase "go" (<3 chars, not an uppercase acronym) is dropped + r1 = _run_bash( + "create-new-feature-branch.sh", project, "--json", "--dry-run", "Add go support", + ) + assert r1.returncode == 0, r1.stderr + assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support" + # uppercase "GO" is kept as an acronym + r2 = _run_bash( + "create-new-feature-branch.sh", project, "--json", "--dry-run", "Use GO now", + ) + assert r2.returncode == 0, r2.stderr + assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now" + def test_creates_branch_timestamp(self, tmp_path: Path): """Extension create-new-feature-branch.sh creates timestamp branch.""" project = _setup_project(tmp_path) @@ -426,6 +466,39 @@ def test_creates_branch_sequential(self, tmp_path: Path): data = json.loads(result.stdout) assert data["BRANCH_NAME"] == "001-user-auth" + def test_output_omits_has_git_to_match_bash(self, tmp_path: Path): + """PowerShell must mirror the bash twin's output contract: neither JSON nor + text output may include HAS_GIT (it is computed internally for branch-creation + logic only). Fails before the fix (PS emitted HAS_GIT), passes after.""" + project = _setup_project(tmp_path) + rj = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-Json", "-DryRun", "-ShortName", "parity", "Parity feature", + ) + assert rj.returncode == 0, rj.stderr + assert "HAS_GIT" not in json.loads(rj.stdout) + rt = _run_pwsh( + "create-new-feature-branch.ps1", project, + "-DryRun", "-ShortName", "parity", "Parity feature", + ) + assert rt.returncode == 0, rt.stderr + assert "HAS_GIT" not in rt.stdout + + def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path): + """PowerShell must match the bash twin: a short word is dropped unless it + appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match).""" + project = _setup_project(tmp_path) + r1 = _run_pwsh( + "create-new-feature-branch.ps1", project, "-Json", "-DryRun", "Add go support", + ) + assert r1.returncode == 0, r1.stderr + assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support" + r2 = _run_pwsh( + "create-new-feature-branch.ps1", project, "-Json", "-DryRun", "Use GO now", + ) + assert r2.returncode == 0, r2.stderr + assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now" + def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path): """Branches checked out in sibling worktrees still reserve their prefix.""" project = _setup_project(tmp_path / "project") diff --git a/tests/extensions/test_agent_context_cli_free.py b/tests/extensions/test_agent_context_cli_free.py new file mode 100644 index 0000000000..9bba8087c0 --- /dev/null +++ b/tests/extensions/test_agent_context_cli_free.py @@ -0,0 +1,57 @@ +"""Static guard: the Specify CLI source must contain no agent-context lifecycle code. + +The ``agent-context`` extension is a full opt-in and owns its own lifecycle. The +Python codebase (``src/specify_cli/**``) must therefore not reference any of the +removed context-section management helpers, the extension config helpers, the +context markers, or the obsolete deprecation message. + +Maps to contract C5 / FR-002 / FR-003 / FR-006 / SC-002 / SC-003. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SRC_ROOT = PROJECT_ROOT / "src" / "specify_cli" + +FORBIDDEN_SYMBOLS = [ + "upsert_context_section", + "remove_context_section", + "_agent_context_extension_enabled", + "_resolve_context_markers", + "_resolve_context_files", + "_resolve_context_file_values", + "_build_context_section", + "_AGENT_CTX_EXT_CONFIG", + "_load_agent_context_config", + "_save_agent_context_config", + "_update_agent_context_config_file", + "CONTEXT_MARKER_START", + "CONTEXT_MARKER_END", + "agent-context-config", + "agent_context_config", + "__CONTEXT_FILE__", + "_context_file_display", + "Inline agent-context updates", + "v0.12.0", +] + + +@pytest.fixture(scope="module") +def cli_source_texts() -> list[tuple[str, str]]: + """Read every CLI source file once, shared across all parametrized cases.""" + return [ + (str(path.relative_to(PROJECT_ROOT)), path.read_text(encoding="utf-8")) + for path in SRC_ROOT.rglob("*.py") + ] + + +@pytest.mark.parametrize("symbol", FORBIDDEN_SYMBOLS) +def test_symbol_absent_from_cli_source(symbol, cli_source_texts): + offenders = [rel for rel, text in cli_source_texts if symbol in text] + assert not offenders, ( + f"Forbidden agent-context symbol {symbol!r} still present in: {offenders}" + ) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index ab4194efd8..f99d449401 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -13,14 +13,9 @@ import yaml from specify_cli import ( - _load_agent_context_config, - _save_agent_context_config, - load_init_options, save_init_options, ) from specify_cli.agents import CommandRegistrar -from specify_cli.integrations.base import IntegrationBase -from specify_cli.integrations.claude import ClaudeIntegration from tests.conftest import requires_bash @@ -33,19 +28,34 @@ def _write_ext_config(project_root: Path, **overrides: object) -> None: - """Write a minimal agent-context extension config.""" + """Write a minimal agent-context extension config directly. + + The CLI no longer owns the extension config β€” the bundled extension does β€” + so tests write it themselves rather than going through any CLI helper. + """ cfg: dict = { "context_file": overrides.get("context_file", ""), "context_files": overrides.get("context_files", []), "context_markers": overrides.get( "context_markers", { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, + "start": "", + "end": "", }, ), } - _save_agent_context_config(project_root, cfg) + path = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(cfg, default_flow_style=False, sort_keys=False), + encoding="utf-8", + ) # ── Bundled extension layout ───────────────────────────────────────────────── @@ -120,19 +130,27 @@ def test_catalog_lists_agent_context_as_bundled(self): assert entry["author"] == "spec-kit-core" -# ── Marker resolution from extension config ────────────────────────────────── - - -class _CtxIntegration(ClaudeIntegration): - """Use Claude as a concrete integration with a context_file.""" - - -class _NoContextIntegration(IntegrationBase): - """Minimal integration with no context_file for base-class fallback tests.""" def _install_agent_context_config(project_root: Path, **overrides: object) -> None: _write_ext_config(project_root, **overrides) + # Mirror the real install layout: the extension ships its own + # agent->context-file defaults map alongside the config. Self-seeding + # tests depend on it, so require it to exist and always copy it rather + # than silently skipping when it is missing. + defaults_src = EXT_DIR / "agent-context-defaults.json" + assert defaults_src.is_file(), ( + f"bundled agent-context defaults map missing: {defaults_src}" + ) + defaults_dst = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-defaults.json" + ) + defaults_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(defaults_src, defaults_dst) def _bash_posix_path(path: Path) -> str: @@ -305,484 +323,6 @@ def _run_powershell_agent_context_script_with_env( ) -class TestContextMarkerResolution: - def test_defaults_when_ext_config_missing(self, tmp_path): - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END - - def test_defaults_when_markers_field_missing(self, tmp_path): - """Config file exists with context_file but no context_markers key.""" - cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" - / "agent-context-config.yml" - ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("context_file: CLAUDE.md\n", encoding="utf-8") - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END - - def test_custom_markers_respected(self, tmp_path): - _write_ext_config( - tmp_path, - context_markers={"start": "", "end": ""}, - ) - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == "" - assert end == "" - - def test_partial_override_falls_back_for_missing_side(self, tmp_path): - _write_ext_config(tmp_path, context_markers={"start": ""}) - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == "" - assert end == IntegrationBase.CONTEXT_MARKER_END - - def test_invalid_markers_fall_back(self, tmp_path): - _write_ext_config(tmp_path, context_markers={"start": 42, "end": ""}) - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END - - -# ── upsert_context_section / remove_context_section honor markers ─────────── - - -class TestUpsertWithCustomMarkers: - def _setup(self, tmp_path: Path, markers: dict | None = None) -> _CtxIntegration: - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - **({"context_markers": markers} if markers is not None else {}), - ) - return _CtxIntegration() - - def test_upsert_uses_default_markers(self, tmp_path): - i = self._setup(tmp_path) - result = i.upsert_context_section(tmp_path) - assert result is not None - text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") - assert IntegrationBase.CONTEXT_MARKER_START in text - assert IntegrationBase.CONTEXT_MARKER_END in text - - def test_upsert_uses_custom_markers(self, tmp_path): - i = self._setup( - tmp_path, {"start": "", "end": ""} - ) - i.upsert_context_section(tmp_path) - text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") - assert "" in text - assert "" in text - # Defaults must not appear - assert IntegrationBase.CONTEXT_MARKER_START not in text - assert IntegrationBase.CONTEXT_MARKER_END not in text - - def test_upsert_replaces_existing_custom_section(self, tmp_path): - i = self._setup( - tmp_path, {"start": "", "end": ""} - ) - ctx = tmp_path / "CLAUDE.md" - ctx.write_text( - "# header\n\n\nold body\n\n\nfooter\n", - encoding="utf-8", - ) - i.upsert_context_section(tmp_path, plan_path="specs/001-foo/plan.md") - text = ctx.read_text(encoding="utf-8") - assert "old body" not in text - assert "specs/001-foo/plan.md" in text - assert text.startswith("# header\n") - assert "footer" in text - - def test_upsert_uses_configured_context_files(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], - ) - i = _CtxIntegration() - result = i.upsert_context_section( - tmp_path, plan_path="specs/001-foo/plan.md" - ) - assert result == tmp_path / "AGENTS.md" - for name in ("AGENTS.md", "CLAUDE.md"): - text = (tmp_path / name).read_text(encoding="utf-8") - assert IntegrationBase.CONTEXT_MARKER_START in text - assert "specs/001-foo/plan.md" in text - - def test_context_files_deduplicate_with_platform_semantics(self, tmp_path): - duplicate = "agents.md" if os.name == "nt" else "AGENTS.md" - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md", duplicate], - ) - - files = _CtxIntegration()._resolve_context_files(tmp_path) - - assert files == ["AGENTS.md", "CLAUDE.md"] - - def test_empty_context_files_falls_back_to_config_context_file(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=[], - ) - - files = _CtxIntegration()._resolve_context_files(tmp_path) - - assert files == ["AGENTS.md"] - - def test_config_context_file_takes_precedence_over_class_default(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - ) - - i = _CtxIntegration() - result = i.upsert_context_section( - tmp_path, plan_path="specs/001-foo/plan.md" - ) - - assert result == tmp_path / "AGENTS.md" - assert (tmp_path / "AGENTS.md").exists() - assert not (tmp_path / "CLAUDE.md").exists() - - def test_config_context_file_fallback_rejects_invalid_path(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="../outside.md", - context_files=[], - ) - - with pytest.raises(ValueError, match="project-relative|must not contain"): - _CtxIntegration()._resolve_context_files(tmp_path) - - def test_remove_uses_configured_context_files(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], - ) - i = _CtxIntegration() - for name in ("AGENTS.md", "CLAUDE.md"): - (tmp_path / name).write_text( - f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n", - encoding="utf-8", - ) - assert i.remove_context_section(tmp_path) is True - for name in ("AGENTS.md", "CLAUDE.md"): - text = (tmp_path / name).read_text(encoding="utf-8") - assert "body" not in text - assert "head" in text - assert "tail" in text - - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "nested/../../outside.md", - "nested\\outside.md", - str(Path("/tmp/outside.md")), - "C:/tmp/outside.md", - "C:tmp/outside.md", - ], - ) - def test_upsert_rejects_context_files_outside_project(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", bad_path], - ) - i = _CtxIntegration() - with pytest.raises(ValueError, match="project-relative|must not contain"): - i.upsert_context_section(tmp_path) - - assert not (tmp_path / "AGENTS.md").exists() - assert not (tmp_path.parent / "outside.md").exists() - - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "nested\\outside.md", - str(Path("/tmp/outside.md")), - "C:/tmp/outside.md", - "C:tmp/outside.md", - ], - ) - def test_remove_rejects_context_files_outside_project(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", bad_path], - ) - outside = tmp_path.parent / "outside.md" - outside.write_text( - f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\n", - encoding="utf-8", - ) - i = _CtxIntegration() - with pytest.raises(ValueError, match="project-relative|must not contain"): - i.remove_context_section(tmp_path) - - assert "body" in outside.read_text(encoding="utf-8") - - def test_remove_uses_custom_markers(self, tmp_path): - i = self._setup( - tmp_path, {"start": "", "end": ""} - ) - ctx = tmp_path / "CLAUDE.md" - ctx.write_text( - "preamble\n\n\nbody\n\nepilogue\n", - encoding="utf-8", - ) - removed = i.remove_context_section(tmp_path) - assert removed is True - remaining = ctx.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "body" not in remaining - assert "preamble" in remaining - assert "epilogue" in remaining - - def test_remove_with_default_markers_unchanged_when_custom_in_file(self, tmp_path): - # Extension config absent β†’ default markers used. File contains only - # custom markers β€” nothing should be removed. - i = _CtxIntegration() - ctx = tmp_path / "CLAUDE.md" - original = "x\n\nbody\n\n" - ctx.write_text(original, encoding="utf-8") - assert i.remove_context_section(tmp_path) is False - assert ctx.read_text(encoding="utf-8") == original - - -# ── Extension disabled gates setup/teardown ────────────────────────────────── - - -def _write_registry(project_root: Path, *, enabled: bool) -> None: - registry = project_root / ".specify" / "extensions" / ".registry" - registry.parent.mkdir(parents=True, exist_ok=True) - registry.write_text( - json.dumps( - { - "schema_version": "1.0", - "extensions": { - "agent-context": { - "version": "1.0.0", - "enabled": enabled, - } - }, - } - ), - encoding="utf-8", - ) - - -class TestExtensionEnabledGate: - def test_enabled_helper_default_when_no_registry(self, tmp_path): - assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True - - def test_enabled_helper_when_entry_present(self, tmp_path): - _write_registry(tmp_path, enabled=True) - assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True - - def test_disabled_helper_when_entry_disabled(self, tmp_path): - _write_registry(tmp_path, enabled=False) - assert IntegrationBase._agent_context_extension_enabled(tmp_path) is False - - def test_upsert_skipped_when_disabled(self, tmp_path): - _write_registry(tmp_path, enabled=False) - i = _CtxIntegration() - result = i.upsert_context_section(tmp_path) - assert result is None - assert not (tmp_path / "CLAUDE.md").exists() - - def test_upsert_disabled_ignores_bad_context_files_config(self, tmp_path): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["../disabled-upsert-outside.md"], - ) - i = _CtxIntegration() - assert i.upsert_context_section(tmp_path) is None - assert not (tmp_path.parent / "disabled-upsert-outside.md").exists() - - def test_remove_skipped_when_disabled(self, tmp_path): - _write_registry(tmp_path, enabled=False) - i = _CtxIntegration() - ctx = tmp_path / "CLAUDE.md" - original = ( - f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n" - ) - ctx.write_text(original, encoding="utf-8") - assert i.remove_context_section(tmp_path) is False - # File must be unchanged when extension is disabled - assert ctx.read_text(encoding="utf-8") == original - - def test_remove_disabled_ignores_bad_context_files_config(self, tmp_path): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["../disabled-remove-outside.md"], - ) - outside = tmp_path.parent / "disabled-remove-outside.md" - outside.write_text( - f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\n", - encoding="utf-8", - ) - i = _CtxIntegration() - assert i.remove_context_section(tmp_path) is False - assert "body" in outside.read_text(encoding="utf-8") - - def test_context_file_display_disabled_uses_config_context_file( - self, tmp_path - ): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["../outside.md"], - ) - i = _CtxIntegration() - assert i._context_file_display(tmp_path) == "AGENTS.md" - - def test_context_file_display_disabled_without_context_file_returns_string( - self, tmp_path - ): - _write_registry(tmp_path, enabled=False) - i = _NoContextIntegration() - assert i._context_file_display(tmp_path) == "" - - -class TestSkillPlaceholderContextValidation: - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "nested/../../outside.md", - "nested\\outside.md", - str(Path("/tmp/outside.md")), - "C:/tmp/outside.md", - "C:tmp/outside.md", - ], - ) - def test_context_files_reject_invalid_config_paths(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["AGENTS.md", bad_path], - ) - - with pytest.raises(ValueError, match="project-relative|must not contain"): - CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "C:tmp/outside.md", - ], - ) - def test_context_file_rejects_invalid_config_path(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file=bad_path, - context_files=[], - ) - - with pytest.raises(ValueError, match="project-relative|must not contain"): - CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - def test_enabled_extension_rejects_invalid_legacy_init_options_path( - self, tmp_path - ): - save_init_options(tmp_path, {"context_file": "../outside.md"}) - - with pytest.raises(ValueError, match="must not contain"): - CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - def test_disabled_extension_ignores_invalid_context_files(self, tmp_path): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["../outside.md"], - ) - save_init_options(tmp_path, {"context_file": "AGENTS.md"}) - - content = CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - assert content == "Read AGENTS.md" - - def test_disabled_extension_uses_extension_context_file_before_init_options( - self, tmp_path - ): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["CLAUDE.md"], - ) - save_init_options(tmp_path, {"context_file": "LEGACY.md"}) - - content = CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - assert content == "Read AGENTS.md" - - def test_context_files_deduplicate_with_platform_semantics(self, tmp_path): - duplicate = "agents.md" if os.name == "nt" else "AGENTS.md" - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["AGENTS.md", "CLAUDE.md", duplicate], - ) - - content = CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - assert content == "Read AGENTS.md, CLAUDE.md" - - class TestBundledUpdaterPathValidation: def test_bundled_script_env_makes_yaml_importable(self, tmp_path): env = _bundled_script_env(tmp_path) @@ -1005,231 +545,329 @@ def test_powershell_script_rejects_junction_escape(self, tmp_path): assert not (outside / "out.md").exists() -# ── Extension config writers ───────────────────────────────────────────────── +# ── CLI does not resolve agent context placeholders ────────────────────────── -class TestExtensionConfigWriters: - def test_clear_init_options_clears_ext_config_context_file(self, tmp_path): - from specify_cli import _clear_init_options_for_integration +class TestSkillPlaceholderContextResolution: + """The CLI no longer resolves any ``__CONTEXT_FILE__`` placeholder. - save_init_options( - tmp_path, - {"integration": "claude", "ai": "claude"}, - ) - _write_ext_config(tmp_path, context_file="CLAUDE.md") - _clear_init_options_for_integration(tmp_path, "claude") - cfg = _load_agent_context_config(tmp_path) - assert cfg.get("context_file") == "" + Agent context files are owned entirely by the opt-in agent-context + extension, so the CLI neither reads integration metadata nor the + extension config when rendering commands/skills. + """ - def test_clear_init_options_creates_ext_config_when_missing(self, tmp_path): - from specify_cli import _clear_init_options_for_integration - - save_init_options( + def test_cli_does_not_resolve_context_placeholder(self, tmp_path): + content = CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", tmp_path, - {"integration": "claude", "ai": "claude"}, ) - _clear_init_options_for_integration(tmp_path, "claude") - cfg = _load_agent_context_config(tmp_path) - assert cfg.get("context_file") == "" - - def test_clear_init_options_removes_legacy_context_keys_even_when_not_active( - self, tmp_path - ): - from specify_cli import _clear_init_options_for_integration + assert content == "Read __CONTEXT_FILE__" - save_init_options( + def test_extension_config_does_not_influence_resolution(self, tmp_path): + # Even a populated extension config must not influence resolution. + _write_ext_config( tmp_path, - { - "integration": "copilot", - "ai": "copilot", - "context_file": "CLAUDE.md", - "context_markers": {"start": "", "end": ""}, - }, + context_file="FROM_CONFIG.md", + context_files=["ALSO_CONFIG.md"], ) - _clear_init_options_for_integration(tmp_path, "claude") - opts = load_init_options(tmp_path) - assert opts["integration"] == "copilot" - assert opts["ai"] == "copilot" - assert "context_file" not in opts - assert "context_markers" not in opts - - def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): - from specify_cli import _update_init_options_for_integration - - # Pre-create the extension config so _update_init_options_for_integration - # updates it (rather than skipping it when ext config doesn't exist yet). - _write_ext_config(tmp_path, context_file="") - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - # init-options.json must NOT have context_file or context_markers - opts = load_init_options(tmp_path) - assert "context_file" not in opts - assert "context_markers" not in opts - # Extension config must have them - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert "context_markers" in cfg - - def test_update_init_options_preserves_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - _write_ext_config( + content = CommandRegistrar.resolve_skill_placeholders( + "claude", + {}, + "Read __CONTEXT_FILE__", tmp_path, - context_file="AGENTS.md", - context_files=["AGENTS.md", "CLAUDE.md"], ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert cfg["context_files"] == ["AGENTS.md", "CLAUDE.md"] + assert "FROM_CONFIG.md" not in content + assert "ALSO_CONFIG.md" not in content + assert content == "Read __CONTEXT_FILE__" - def test_update_init_options_preserves_empty_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=[], +# ── CLI no longer owns the agent-context extension config ──────────────────── + + +class TestCliDoesNotManageExtensionConfig: + """The Python codebase must not read or write the extension config.""" + + def test_config_helpers_are_removed(self): + import specify_cli + + for name in ( + "_load_agent_context_config", + "_save_agent_context_config", + "_update_agent_context_config_file", + "_AGENT_CTX_EXT_CONFIG", + ): + assert not hasattr(specify_cli, name), name + + def test_no_agent_context_config_symbols_in_source(self): + src = PROJECT_ROOT / "src" / "specify_cli" + offenders = [] + for path in src.rglob("*.py"): + text = path.read_text(encoding="utf-8") + if "agent-context-config" in text or "agent_context_config" in text: + offenders.append(str(path.relative_to(PROJECT_ROOT))) + assert not offenders, offenders + + def test_update_init_options_does_not_create_ext_config(self, tmp_path): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations._helpers import ( + _update_init_options_for_integration, ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert cfg["context_files"] == [] - - def test_update_init_options_normalizes_invalid_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - - _write_ext_config(tmp_path, context_file="AGENTS.md") - cfg = _load_agent_context_config(tmp_path) - cfg["context_files"] = "AGENTS.md" - _save_agent_context_config(tmp_path, cfg) - - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert cfg["context_files"] == [] - - def test_clear_init_options_clears_context_files(self, tmp_path): - from specify_cli import _clear_init_options_for_integration - - save_init_options( - tmp_path, - {"integration": "claude", "ai": "claude"}, + + _update_init_options_for_integration( + tmp_path, INTEGRATION_REGISTRY["claude"], script_type="sh" ) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], + + cfg = ( + tmp_path + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - _clear_init_options_for_integration(tmp_path, "claude") - cfg = _load_agent_context_config(tmp_path) - assert cfg.get("context_file") == "" - assert "context_files" not in cfg + assert not cfg.exists() - def test_update_init_options_preserves_custom_markers(self, tmp_path): - from specify_cli import _update_init_options_for_integration + def test_clear_init_options_does_not_create_ext_config(self, tmp_path): + from specify_cli.integrations._helpers import ( + _clear_init_options_for_integration, + ) - _write_ext_config( - tmp_path, - context_file="", - context_markers={"start": "", "end": ""}, + save_init_options(tmp_path, {"integration": "claude", "ai": "claude"}) + _clear_init_options_for_integration(tmp_path, "claude") + + cfg = ( + tmp_path + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i) - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_markers"] == {"start": "", "end": ""} + assert not cfg.exists() - def test_reinit_preserves_custom_markers(self, tmp_path): - """specify init (reinit) must not overwrite user-customised markers.""" - from specify_cli import _update_agent_context_config_file - # Simulate existing project with custom markers - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_markers={"start": "", "end": ""}, +# ── Extension self-seeds its target from the active integration ────────────── + + +class TestExtensionSelfSeed: + """When its own config declares no target, the bundled extension derives + the context file from the active integration using its OWN bundled + agent->context-file defaults map (no Specify CLI dependency).""" + + @requires_bash + def test_bash_script_self_seeds_from_active_integration(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + # Config present but empty β€” no context_file / context_files. + _install_agent_context_config(project, context_file="", context_files=[]) + # Active integration recorded in init-options.json (codex -> AGENTS.md). + save_init_options(project, {"integration": "codex", "ai": "codex"}) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout) + assert (project / "AGENTS.md").exists() + assert "" in ( + project / "AGENTS.md" + ).read_text(encoding="utf-8") + + @requires_bash + def test_bash_script_nothing_to_do_without_integration(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file="", context_files=[]) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + assert "nothing to do" in (result.stderr + result.stdout) + + +_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" + + +class TestMdcFrontmatter: + """Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter + so the rule file is auto-loaded; non-``.mdc`` targets must not gain any.""" + + @requires_bash + def test_bash_script_prepends_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8") + assert text.startswith("---\nalwaysApply: true\n---\n") + assert "" in text + + @requires_bash + def test_bash_script_mdc_frontmatter_is_idempotent(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + + _run_bash_agent_context_script(project) + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8") + assert text.count("alwaysApply: true") == 1 + + @requires_bash + def test_bash_script_repairs_existing_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + target = project / _MDC_CONTEXT_FILE + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + "---\ndescription: My rules\nalwaysApply: false\n---\n\nUser notes\n", + encoding="utf-8", ) - # Re-running init updates context_file but must preserve markers - _update_agent_context_config_file( - tmp_path, "CLAUDE.md", preserve_markers=True + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = target.read_text(encoding="utf-8") + assert "alwaysApply: true" in text + assert "alwaysApply: false" not in text + assert "description: My rules" in text + assert "User notes" in text + + @requires_bash + def test_bash_script_skips_frontmatter_for_non_mdc(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file="AGENTS.md") + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / "AGENTS.md").read_text(encoding="utf-8") + assert "alwaysApply" not in text + assert text.startswith("") + + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_prepends_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + + result = _run_powershell_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8") + assert text.startswith("---\nalwaysApply: true\n---\n") + assert "" in text + + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_repairs_existing_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + target = project / _MDC_CONTEXT_FILE + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + "---\ndescription: My rules\nalwaysApply: false\n---\n\nUser notes\n", + encoding="utf-8", ) - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_markers"] == { - "start": "", - "end": "", - } + result = _run_powershell_agent_context_script(project) -# ── Deprecation warning on upsert ──────────────────────────────────────────── + assert result.returncode == 0, result.stderr + result.stdout + text = target.read_text(encoding="utf-8") + assert "alwaysApply: true" in text + assert "alwaysApply: false" not in text + assert "description: My rules" in text + assert "User notes" in text + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_skips_frontmatter_for_non_mdc(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file="AGENTS.md") -class TestDeprecationWarning: - def test_upsert_emits_deprecation_warning(self, tmp_path, capsys): - """upsert_context_section must emit a deprecation notice on stdout.""" - from tests.conftest import strip_ansi + result = _run_powershell_agent_context_script(project) - i = _CtxIntegration() - _write_ext_config(tmp_path, context_file="CLAUDE.md") - i.upsert_context_section(tmp_path) - captured = capsys.readouterr() - plain = strip_ansi(captured.out) - assert "Deprecation" in plain - assert "v0.12.0" in plain - assert "agent-context" in plain + assert result.returncode == 0, result.stderr + result.stdout + text = (project / "AGENTS.md").read_text(encoding="utf-8") + assert "alwaysApply" not in text + assert text.startswith("") + + +_LEGACY_CONTEXT = ( + "# CLAUDE.md\n\n" + "Some user notes.\n\n" + "\n" + "Legacy managed section written by an older Spec Kit version.\n" + "\n\n" + "More user notes.\n" +) - def test_upsert_no_warning_when_disabled(self, tmp_path, capsys): - """No deprecation warning when agent-context extension is disabled.""" - _write_registry(tmp_path, enabled=False) - i = _CtxIntegration() - i.upsert_context_section(tmp_path) - captured = capsys.readouterr() - assert "Deprecation" not in captured.out +class TestBackwardCompatibility: + """Legacy projects must keep working; the CLI never touches their artifacts.""" -# ── Corrupt / invalid extension config ─────────────────────────────────────── + def _seed_legacy_project(self, project_root: Path) -> Path: + ctx = project_root / "CLAUDE.md" + ctx.write_text(_LEGACY_CONTEXT, encoding="utf-8") + _write_ext_config(project_root, context_file="CLAUDE.md") + save_init_options(project_root, {"integration": "claude", "ai": "claude"}) + return ctx + def test_integration_setup_leaves_legacy_artifacts_untouched(self, tmp_path): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations.manifest import IntegrationManifest -class TestCorruptExtensionConfig: - def test_marker_resolution_with_corrupt_yaml(self, tmp_path): - """Corrupt YAML in agent-context-config.yml falls back to defaults.""" + project = tmp_path / "legacy" + project.mkdir() + ctx = self._seed_legacy_project(project) cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" + project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text(": invalid: yaml: {{{\n", encoding="utf-8") - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END - - def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path): - """upsert_context_section still works when config YAML is corrupt.""" - cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" - / "agent-context-config.yml" + before_ctx = ctx.read_text(encoding="utf-8") + before_cfg = cfg_path.read_text(encoding="utf-8") + + integration = INTEGRATION_REGISTRY["claude"] + m = IntegrationManifest("claude", project) + integration.setup(project, m) + + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg + + def test_integration_switch_and_uninstall_leave_legacy_artifacts_untouched( + self, tmp_path + ): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations._helpers import ( + _clear_init_options_for_integration, + _update_init_options_for_integration, ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("not valid yaml: {{{\n", encoding="utf-8") - i = _CtxIntegration() - result = i.upsert_context_section(tmp_path) - assert result is not None - text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") - assert IntegrationBase.CONTEXT_MARKER_START in text - assert IntegrationBase.CONTEXT_MARKER_END in text - - def test_marker_resolution_with_non_dict_yaml(self, tmp_path): - """Config file containing a scalar (not a dict) falls back to defaults.""" + + project = tmp_path / "legacy" + project.mkdir() + ctx = self._seed_legacy_project(project) cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" + project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("just a string\n", encoding="utf-8") - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END + before_ctx = ctx.read_text(encoding="utf-8") + before_cfg = cfg_path.read_text(encoding="utf-8") + + # Switch to a different integration. + _update_init_options_for_integration( + project, INTEGRATION_REGISTRY["gemini"], script_type="sh" + ) + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg + + # Uninstall. + _clear_init_options_for_integration(project, "gemini") + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py new file mode 100644 index 0000000000..957415708c --- /dev/null +++ b/tests/extensions/test_update_agent_context_feature_json.py @@ -0,0 +1,211 @@ +"""Tests that update-agent-context.sh/.ps1 prefer feature.json over mtime.""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash +from tests.extensions.test_extension_agent_context import ( + BASH, + POWERSHELL, + _bash_posix_path, + _run_bash_agent_context_script, + _run_powershell_agent_context_script, +) + + +def _setup_project(root: Path, context_file: str = "CLAUDE.md") -> None: + """Write agent-context extension config as JSON. + + JSON is valid YAML so bash+PyYAML can parse it, and PowerShell's built-in + ConvertFrom-Json can parse it without needing powershell-yaml or Python. + Written directly as JSON (not via yaml.safe_dump) so the PS ConvertFrom-Json + fallback actually works on Windows CI. + """ + cfg_dir = root / ".specify" / "extensions" / "agent-context" + cfg_dir.mkdir(parents=True, exist_ok=True) + (cfg_dir / "agent-context-config.yml").write_text( + json.dumps({ + "context_file": context_file, + "context_markers": { + "start": "", + "end": "", + }, + }), + encoding="utf-8", + ) + + +def _write_feature_json(root: Path, feature_directory: str) -> None: + specify_dir = root / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + (specify_dir / "feature.json").write_text( + json.dumps({"feature_directory": feature_directory}), + encoding="utf-8", + ) + + +def _make_plan(root: Path, feature_dir: str, content: str = "# plan\n") -> Path: + p = root / feature_dir / "plan.md" + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content, encoding="utf-8") + return p + + +@requires_bash +def test_bash_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: + """feature.json points to the active feature; that plan.md is injected.""" + _setup_project(tmp_path) + _make_plan(tmp_path, "specs/001-active") + _write_feature_json(tmp_path, "specs/001-active") + + result = _run_bash_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + + +@requires_bash +def test_bash_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None: + """An older spec's plan.md modified more recently must NOT win over feature.json.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + _write_feature_json(tmp_path, "specs/001-active") + + result = _run_bash_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + + +@requires_bash +def test_bash_falls_back_to_mtime_when_feature_json_absent(tmp_path: Path) -> None: + """No feature.json β†’ mtime fallback selects the most recently modified plan.""" + _setup_project(tmp_path) + old = _make_plan(tmp_path, "specs/000-old") + newer = _make_plan(tmp_path, "specs/001-newer") + now = time.time() + os.utime(old, (now - 10, now - 10)) + os.utime(newer, (now, now)) + + result = _run_bash_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-newer/plan.md" in ctx + + +@requires_bash +def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> None: + """feature.json exists but plan.md not yet written β†’ fall back to mtime.""" + _setup_project(tmp_path) + _make_plan(tmp_path, "specs/000-old") + _write_feature_json(tmp_path, "specs/001-new") + + result = _run_bash_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/000-old/plan.md" in ctx + + +@requires_bash +def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None: + """Absolute feature_directory under PROJECT_ROOT β†’ project-relative path in context.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + # Write POSIX absolute path β€” mtime would pick 000-stale without feature.json + _write_feature_json(tmp_path, _bash_posix_path(tmp_path / "specs" / "001-active")) + + result = _run_bash_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + assert _bash_posix_path(tmp_path) not in ctx + + +@requires_bash +def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: + """Absolute feature_directory outside PROJECT_ROOT β†’ absolute path preserved in context.""" + project = tmp_path / "project" + external = tmp_path / "external" / "001-feature" + project.mkdir() + external.mkdir(parents=True) + (external / "plan.md").write_text("# plan\n", encoding="utf-8") + + _setup_project(project) + _write_feature_json(project, _bash_posix_path(external)) + + result = _run_bash_agent_context_script(project) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (project / "CLAUDE.md").read_text(encoding="utf-8") + assert _bash_posix_path(external) + "/plan.md" in ctx + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None: + """PowerShell: absolute feature_directory under project root is normalized to relative path.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + # Native str() β€” PowerShell expects Windows-native paths, not MSYS2 /c/... form + _write_feature_json(tmp_path, str(tmp_path / "specs" / "001-active")) + + result = _run_powershell_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "at specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + assert tmp_path.resolve().as_posix() not in ctx + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_ps_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None: + """PowerShell: stale plan touched more recently must not win over feature.json.""" + _setup_project(tmp_path) + active = _make_plan(tmp_path, "specs/001-active") + stale = _make_plan(tmp_path, "specs/000-stale") + now = time.time() + os.utime(active, (now - 10, now - 10)) + os.utime(stale, (now, now)) + _write_feature_json(tmp_path, "specs/001-active") + + result = _run_powershell_agent_context_script(tmp_path) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "specs/001-active/plan.md" in ctx + assert "specs/000-stale/plan.md" not in ctx + + +@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available") +def test_ps_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None: + """PowerShell: absolute feature_directory outside project root β†’ absolute path preserved.""" + project = tmp_path / "project" + external = tmp_path / "external" / "001-feature" + project.mkdir() + external.mkdir(parents=True) + (external / "plan.md").write_text("# plan\n", encoding="utf-8") + + _setup_project(project) + _write_feature_json(project, str(external)) + + result = _run_powershell_agent_context_script(project) + assert result.returncode == 0, result.stderr + result.stdout + ctx = (project / "CLAUDE.md").read_text(encoding="utf-8") + assert external.resolve().as_posix() + "/plan.md" in ctx diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py index 42135402a9..4671872356 100644 --- a/tests/integrations/conftest.py +++ b/tests/integrations/conftest.py @@ -36,4 +36,3 @@ class StubIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "STUB.md" diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 47f9d09059..9ec7d236c1 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -43,7 +43,6 @@ def test_key_and_config(self): assert i.key == "stub" assert i.config["name"] == "Stub Agent" assert i.registrar_config["format"] == "markdown" - assert i.context_file == "STUB.md" def test_options_default_empty(self): assert StubIntegration.options() == [] diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index be8aad2326..25d4a7c16a 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -77,23 +77,17 @@ def test_integration_copilot_creates_files(self, tmp_path): opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) assert opts["integration"] == "copilot" - # context_file lives in the agent-context extension config, not init-options.json + # init must not leave any legacy agent-context keys in init-options.json assert "context_file" not in opts - import yaml as _yaml + # agent-context is fully opt-in: init must not install it or write its config ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - assert ext_cfg_path.exists(), "agent-context extension config must be created on init" - ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) - assert ext_cfg["context_file"] == ".github/copilot-instructions.md" + assert not ext_cfg_path.exists(), "init must not create the agent-context extension config" assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() - # Context section should be upserted into the copilot instructions file - ctx_file = project / ".github" / "copilot-instructions.md" - assert ctx_file.exists() - ctx_content = ctx_file.read_text(encoding="utf-8") - assert "" in ctx_content - assert "" in ctx_content + # init must not create or manage the agent context file + assert not (project / ".github" / "copilot-instructions.md").exists() shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" assert shared_manifest.exists() @@ -1270,7 +1264,6 @@ class BrokenIntegration(IntegrationBase): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "BROKEN.md" def setup(self, project_root, manifest, **kwargs): raise OSError("setup exploded\nwith context") diff --git a/tests/integrations/test_extra_args.py b/tests/integrations/test_extra_args.py index d192e140fb..e329c88801 100644 --- a/tests/integrations/test_extra_args.py +++ b/tests/integrations/test_extra_args.py @@ -37,7 +37,6 @@ class _ClaudeStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "CLAUDE.md" class _KiroCliStub(SkillsIntegration): @@ -58,7 +57,6 @@ class _KiroCliStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "KIRO.md" class _NoCliStub(SkillsIntegration): @@ -79,7 +77,6 @@ class _NoCliStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "NOCLI.md" class _MarkdownAgentStub(MarkdownIntegration): @@ -102,7 +99,6 @@ class _MarkdownAgentStub(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "MDAGENT.md" class _TomlAgentStub(TomlIntegration): @@ -124,7 +120,6 @@ class _TomlAgentStub(TomlIntegration): "args": "$ARGUMENTS", "extension": ".toml", } - context_file = "TOMLAGENT.md" @pytest.fixture(autouse=True) diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index b64a609e15..6ab66a0cbe 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -10,7 +10,6 @@ class TestAgyIntegration(SkillsIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".agents/skills" - CONTEXT_FILE = "AGENTS.md" def test_options_include_skills_flag(self): """Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout.""" diff --git a/tests/integrations/test_integration_amp.py b/tests/integrations/test_integration_amp.py index a36dd47136..f0689c21f5 100644 --- a/tests/integrations/test_integration_amp.py +++ b/tests/integrations/test_integration_amp.py @@ -8,4 +8,3 @@ class TestAmpIntegration(MarkdownIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".agents/commands" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_auggie.py b/tests/integrations/test_integration_auggie.py index e4033a23e8..3cf4d09bbc 100644 --- a/tests/integrations/test_integration_auggie.py +++ b/tests/integrations/test_integration_auggie.py @@ -8,4 +8,3 @@ class TestAuggieIntegration(MarkdownIntegrationTests): FOLDER = ".augment/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".augment/commands" - CONTEXT_FILE = ".augment/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index b0b408a995..886dfb912f 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard MarkdownIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``MarkdownIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``MarkdownIntegrationTests``. """ import os @@ -21,14 +21,12 @@ class MarkdownIntegrationTests: FOLDER: str β€” e.g. ".claude/" COMMANDS_SUBDIR: str β€” e.g. "commands" REGISTRAR_DIR: str β€” e.g. ".claude/commands" - CONTEXT_FILE: str β€” e.g. "CLAUDE.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -56,10 +54,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "$ARGUMENTS" assert i.registrar_config["extension"] == ".md" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -101,19 +95,18 @@ def test_templates_are_processed(self, tmp_path): assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__" assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference this integration's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ 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}" ) @@ -149,35 +142,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file β€” that is + owned entirely by the opt-in agent-context extension.""" 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): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - # Add user content around the section - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -225,35 +215,10 @@ 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): - """agent-context extension config must include context_file for the active integration.""" - import yaml - 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", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", ] @@ -293,19 +258,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index e903d918e2..d88b786757 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard SkillsIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``SkillsIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``SkillsIntegrationTests``. Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely, adapted for the ``speckit-/SKILL.md`` skills layout. @@ -26,14 +26,12 @@ class SkillsIntegrationTests: FOLDER: str β€” e.g. ".agents/" COMMANDS_SUBDIR: str β€” e.g. "skills" REGISTRAR_DIR: str β€” e.g. ".agents/skills" - CONTEXT_FILE: str β€” e.g. "AGENTS.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -61,10 +59,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "$ARGUMENTS" assert i.registrar_config["extension"] == "/SKILL.md" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -222,19 +216,18 @@ 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.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path): + """The generated plan skill must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan skill must not reference one. + """ 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" ) @@ -283,34 +276,32 @@ def test_pre_existing_skills_not_removed(self, tmp_path): assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file β€” that is + owned entirely by the opt-in agent-context extension.""" 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): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") 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 + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -356,9 +347,9 @@ 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): - """agent-context extension config must include context_file for the active integration.""" - import yaml + def test_init_does_not_create_agent_context_config(self, tmp_path): + """agent-context is opt-in: init must not auto-install the extension + or write its config.""" from typer.testing import CliRunner from specify_cli import app @@ -375,11 +366,7 @@ def test_init_options_includes_context_file(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) + assert not ext_cfg_path.exists() # -- IntegrationOption ------------------------------------------------ @@ -406,8 +393,6 @@ def _expected_files(self, script_variant: str) -> list[str]: # Skill files (core commands) for cmd in self._SKILL_COMMANDS: files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md") - # Extension-installed skill (agent-context) - files.append(f"{skills_prefix}/speckit-agent-context-update/SKILL.md") # Integration metadata files += [ ".specify/init-options.json", @@ -446,18 +431,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/workflows/speckit/workflow.yml", ".specify/workflows/workflow-registry.json", ] - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - # 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 a9b933875a..68f5fd075a 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard TomlIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``TomlIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``TomlIntegrationTests``. Mirrors ``MarkdownIntegrationTests`` closely β€” same test structure, adapted for TOML output format. @@ -27,14 +27,12 @@ class TomlIntegrationTests: FOLDER: str β€” e.g. ".gemini/" COMMANDS_SUBDIR: str β€” e.g. "commands" REGISTRAR_DIR: str β€” e.g. ".gemini/commands" - CONTEXT_FILE: str β€” e.g. "GEMINI.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -62,10 +60,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "{{args}}" assert i.registrar_config["extension"] == ".toml" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -311,19 +305,18 @@ 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.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ 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}" ) @@ -359,34 +352,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file β€” that is + owned entirely by the opt-in agent-context extension.""" 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): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") 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 + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -454,35 +445,10 @@ def test_integration_flag_creates_files(self, tmp_path): 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): - """agent-context extension config must include context_file for the active integration.""" - import yaml - 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", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", @@ -544,19 +510,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 646e21607d..74cdab2d7d 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -1,8 +1,8 @@ """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``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``YamlIntegrationTests``. Mirrors ``TomlIntegrationTests`` closely β€” same test structure, adapted for YAML recipe output format. @@ -26,14 +26,12 @@ class YamlIntegrationTests: 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 ----------------------------------------------------- @@ -61,10 +59,6 @@ def test_registrar_config(self): 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): @@ -190,19 +184,18 @@ def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): 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.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ 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}" ) @@ -238,34 +231,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file β€” that is + owned entirely by the opt-in agent-context extension.""" 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): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") 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 + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -333,35 +324,10 @@ def test_integration_flag_creates_files(self, tmp_path): 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): - """agent-context extension config must include context_file for the active integration.""" - import yaml - 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", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", @@ -423,19 +389,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py index 1562f0100c..8e0e72f0bd 100644 --- a/tests/integrations/test_integration_bob.py +++ b/tests/integrations/test_integration_bob.py @@ -8,4 +8,3 @@ class TestBobIntegration(MarkdownIntegrationTests): FOLDER = ".bob/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".bob/commands" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index fae9e32d23..6b6831e05c 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -67,6 +67,22 @@ def test_missing_host_rejected(self): with pytest.raises(IntegrationCatalogError, match="valid URL"): IntegrationCatalog._validate_catalog_url("https:///no-host") + @pytest.mark.parametrize( + "url", + [ + "https://:8080", # port only, no host + "https://:0", # port only, no host + "https://user@", # userinfo only, no host + "https://user:pw@", # userinfo only, no host + ], + ) + def test_hostless_url_with_truthy_netloc_rejected(self, url): + # These have a truthy netloc (":8080", "user@") but no actual host, + # so a netloc-based check would wrongly accept them despite the + # "valid URL with a host" promise. hostname is None for all of them. + with pytest.raises(IntegrationCatalogError, match="valid URL"): + IntegrationCatalog._validate_catalog_url(url) + # --------------------------------------------------------------------------- # IntegrationCatalog β€” active catalogs diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index c7ecef95d0..1b1b2308d7 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,6 +1,5 @@ """Tests for ClaudeIntegration.""" -import codecs import json import os from pathlib import Path @@ -34,10 +33,6 @@ def test_registrar_config_uses_skill_layout(self): assert integration.registrar_config["args"] == "$ARGUMENTS" assert integration.registrar_config["extension"] == "/SKILL.md" - def test_context_file(self): - integration = get_integration("claude") - assert integration.context_file == "CLAUDE.md" - def test_setup_creates_skill_files(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) @@ -76,57 +71,30 @@ def test_render_skill_unicode(self): ) assert "PrΓΌfe KonformitΓ€t" in rendered - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """The CLI no longer manages the agent context file β€” that is owned by + the opt-in agent-context extension. Setup must not create or touch it.""" integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) integration.setup(tmp_path, manifest, script_type="sh") - ctx_path = tmp_path / integration.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_upsert_context_section_strips_bom(self, tmp_path): - """Existing context file with UTF-8 BOM must be cleaned up on upsert.""" - integration = get_integration("claude") - ctx_path = tmp_path / integration.context_file - - # Write a file that starts with a UTF-8 BOM (as the old PowerShell script did) - bom = codecs.BOM_UTF8 - ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n") + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text - 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.""" + def test_teardown_does_not_touch_existing_context_file(self, tmp_path): + """A user-authored context file is left intact on teardown.""" integration = get_integration("claude") - ctx_path = tmp_path / integration.context_file + ctx_path = tmp_path / "CLAUDE.md" + original = "# CLAUDE.md\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") - 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) + manifest = IntegrationManifest("claude", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + integration.teardown(tmp_path, manifest) - 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"", - "end": "", - }, - }, - ) integration = get_integration("codex") manifest = IntegrationManifest("codex", target) @@ -53,43 +40,31 @@ def test_plan_skill_references_configured_context_files(self, tmp_path): plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" content = plan_skill.read_text(encoding="utf-8") - assert "AGENTS.md, CLAUDE.md" in content assert "__CONTEXT_FILE__" not in content - def test_plan_skill_ignores_context_files_when_agent_context_disabled( - self, tmp_path - ): - """Disabled agent-context must not leak stale context_files into commands.""" - from specify_cli import _save_agent_context_config + def test_plan_skill_ignores_extension_config(self, tmp_path): + """The extension config must not influence rendered commands: the CLI + no longer reads any context-file metadata when rendering.""" + import yaml target = tmp_path / "test-proj" target.mkdir() - registry = target / ".specify" / "extensions" / ".registry" - registry.parent.mkdir(parents=True, exist_ok=True) - registry.write_text( - """ -{ - "schema_version": "1.0", - "extensions": { - "agent-context": { - "version": "1.0.0", - "enabled": false - } - } -} -""".strip(), - encoding="utf-8", + ext_cfg = ( + target + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - _save_agent_context_config( - target, - { - "context_file": "AGENTS.md", - "context_files": ["../outside.md", "CLAUDE.md"], - "context_markers": { - "start": "", - "end": "", - }, - }, + ext_cfg.parent.mkdir(parents=True, exist_ok=True) + ext_cfg.write_text( + yaml.safe_dump( + { + "context_file": "FROM_CONFIG.md", + "context_files": ["FROM_CONFIG.md", "ALSO_CONFIG.md"], + } + ), + encoding="utf-8", ) integration = get_integration("codex") @@ -98,9 +73,8 @@ def test_plan_skill_ignores_context_files_when_agent_context_disabled( plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" content = plan_skill.read_text(encoding="utf-8") - assert "AGENTS.md, CLAUDE.md" not in content - assert "../outside.md" not in content - assert "AGENTS.md" in content + assert "FROM_CONFIG.md" not in content + assert "ALSO_CONFIG.md" not in content assert "__CONTEXT_FILE__" not in content diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 6b7cc7c13f..8a7c8ec995 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -17,7 +17,6 @@ def test_copilot_key_and_config(self): assert copilot.config["folder"] == ".github/" assert copilot.config["commands_subdir"] == "agents" assert copilot.registrar_config["extension"] == ".agent.md" - assert copilot.context_file == ".github/copilot-instructions.md" def test_command_filename_agent_md(self): copilot = get_integration("copilot") @@ -162,8 +161,9 @@ def test_specify_agent_resolves_active_spec_template(self, tmp_path): assert "Copy `.specify/templates/spec-template.md`" not in content assert "Load `.specify/templates/spec-template.md`" not in content - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference copilot's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder β€” + agent context files are owned by the opt-in agent-context extension.""" from specify_cli.integrations.copilot import CopilotIntegration copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) @@ -171,9 +171,6 @@ def test_plan_references_correct_context_file(self, tmp_path): plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert copilot.context_file in content, ( - f"Plan command should reference {copilot.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_complete_file_inventory_sh(self, tmp_path): @@ -193,7 +190,6 @@ def test_complete_file_inventory_sh(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - ".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", @@ -204,7 +200,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/agents/speckit.specify.agent.md", ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", - ".github/prompts/speckit.agent-context.update.prompt.md", ".github/prompts/speckit.analyze.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", @@ -216,15 +211,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", - ".github/copilot-instructions.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -265,7 +251,6 @@ def test_complete_file_inventory_ps(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - ".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", @@ -276,7 +261,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/agents/speckit.specify.agent.md", ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", - ".github/prompts/speckit.agent-context.update.prompt.md", ".github/prompts/speckit.analyze.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", @@ -288,15 +272,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", - ".github/copilot-instructions.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -537,14 +512,14 @@ 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 copilot's context file.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path): + """The core plan skill must not carry a context-file placeholder β€” + agent context files are owned by the opt-in agent-context extension.""" copilot = self._make_copilot() self._setup_skills(copilot, tmp_path) plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert copilot.context_file in content assert "__CONTEXT_FILE__" not in content # -- Manifest tracking ------------------------------------------------ @@ -603,14 +578,13 @@ def test_build_command_invocation_default_mode(self): # -- Context section --------------------------------------------------- - def test_skills_setup_upserts_context_section(self, tmp_path): + def test_skills_setup_does_not_write_context_section(self, tmp_path): copilot = self._make_copilot() self._setup_skills(copilot, tmp_path) - ctx_path = tmp_path / copilot.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text # -- CLI integration test --------------------------------------------- @@ -659,20 +633,8 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): 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() and ".git" not in p.parts) expected = sorted([ - # Skill files (core + extension-installed agent-context command) + # Skill files (core commands) *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], - ".github/skills/speckit-agent-context-update/SKILL.md", - # Context file - ".github/copilot-instructions.md", - # Bundled agent-context extension - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", # Integration metadata ".specify/init-options.json", ".specify/integration.json", diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 8165464655..32318dc90f 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -1,10 +1,8 @@ """Tests for CursorAgentIntegration.""" -from pathlib import Path from urllib.parse import urlparse from specify_cli.integrations import get_integration -from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_skills import SkillsIntegrationTests @@ -14,82 +12,6 @@ class TestCursorAgentIntegration(SkillsIntegrationTests): FOLDER = ".cursor/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".cursor/skills" - CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" - - -class TestCursorMdcFrontmatter: - """Verify .mdc frontmatter handling in upsert/remove context section.""" - - def _setup(self, tmp_path: Path): - i = get_integration("cursor-agent") - m = IntegrationManifest("cursor-agent", tmp_path) - return i, m - - def test_new_mdc_gets_frontmatter(self, tmp_path): - """A freshly created .mdc file includes alwaysApply: true.""" - i, m = self._setup(tmp_path) - i.setup(tmp_path, m) - ctx = (tmp_path / i.context_file).read_text(encoding="utf-8") - assert ctx.startswith("---\n") - assert "alwaysApply: true" in ctx - - def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path): - """An existing .mdc without frontmatter gets it added.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text("# User rules\n", encoding="utf-8") - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert content.lstrip().startswith("---") - assert "alwaysApply: true" in content - assert "# User rules" in content - - def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path): - """An existing .mdc with custom frontmatter is preserved.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text( - "---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n", - encoding="utf-8", - ) - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert "alwaysApply: true" in content - assert "customKey: hello" in content - assert "" in content - - def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path): - """An .mdc with alwaysApply: false gets corrected.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text( - "---\nalwaysApply: false\n---\n\n# Rules\n", - encoding="utf-8", - ) - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert "alwaysApply: true" in content - assert "alwaysApply: false" not in content - - def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path): - """Repeated upserts don't duplicate frontmatter.""" - i, m = self._setup(tmp_path) - i.upsert_context_section(tmp_path) - i.upsert_context_section(tmp_path) - content = (tmp_path / i.context_file).read_text(encoding="utf-8") - assert content.count("alwaysApply") == 1 - - def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path): - """Removing the section from a Speckit-only .mdc deletes the file.""" - i, m = self._setup(tmp_path) - i.upsert_context_section(tmp_path) - ctx_path = tmp_path / i.context_file - assert ctx_path.exists() - i.remove_context_section(tmp_path) - assert not ctx_path.exists() class TestCursorAgentInitFlow: diff --git a/tests/integrations/test_integration_devin.py b/tests/integrations/test_integration_devin.py index 4acbdac618..52c2981bf1 100644 --- a/tests/integrations/test_integration_devin.py +++ b/tests/integrations/test_integration_devin.py @@ -8,7 +8,6 @@ class TestDevinIntegration(SkillsIntegrationTests): FOLDER = ".devin/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".devin/skills" - CONTEXT_FILE = "AGENTS.md" class TestDevinBuildExecArgs: diff --git a/tests/integrations/test_integration_firebender.py b/tests/integrations/test_integration_firebender.py index b42d2fbf9d..6de66f4d07 100644 --- a/tests/integrations/test_integration_firebender.py +++ b/tests/integrations/test_integration_firebender.py @@ -11,7 +11,6 @@ class TestFirebenderIntegration(MarkdownIntegrationTests): FOLDER = ".firebender/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".firebender/commands" - CONTEXT_FILE = ".firebender/rules/specify-rules.mdc" # Firebender reads custom slash commands from ``.firebender/commands/*.mdc``, # so this integration uses the ``.mdc`` extension instead of the ``.md`` diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index f63afb71e2..26ac7a9931 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -55,7 +55,6 @@ def test_forge_key_and_config(self): assert forge.config["requires_cli"] is True assert forge.registrar_config["args"] == "{{parameters}}" assert forge.registrar_config["extension"] == ".md" - assert forge.context_file == "AGENTS.md" def test_command_filename_md(self): forge = get_integration("forge") @@ -73,16 +72,15 @@ def test_setup_creates_md_files(self, tmp_path): for f in command_files: assert f.name.endswith(".md") - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) forge.setup(tmp_path, m) - ctx_path = tmp_path / forge.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text def test_all_created_files_tracked_in_manifest(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration @@ -164,8 +162,9 @@ def test_templates_are_processed(self, tmp_path): "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.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder β€” + agent context files are owned by the opt-in agent-context extension.""" from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) @@ -173,9 +172,6 @@ def test_plan_references_correct_context_file(self, tmp_path): 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): diff --git a/tests/integrations/test_integration_gemini.py b/tests/integrations/test_integration_gemini.py index 9be5985e29..1649b4f7c3 100644 --- a/tests/integrations/test_integration_gemini.py +++ b/tests/integrations/test_integration_gemini.py @@ -8,4 +8,3 @@ class TestGeminiIntegration(TomlIntegrationTests): FOLDER = ".gemini/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".gemini/commands" - CONTEXT_FILE = "GEMINI.md" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index fe935cc98b..1c5edc2efc 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -31,10 +31,6 @@ def test_config_requires_cli_false(self): i = get_integration("generic") assert i.config["requires_cli"] is False - def test_context_file_is_agents_md(self): - i = get_integration("generic") - assert i.context_file == "AGENTS.md" - # -- Options ---------------------------------------------------------- def test_options_include_commands_dir(self): @@ -161,28 +157,24 @@ def test_different_commands_dirs(self, tmp_path): # -- Context section --------------------------------------------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference generic's context file.""" + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text + + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder β€” + agent context files are owned by the opt-in agent-context extension.""" i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_plan_defines_quickstart_as_validation_guide(self, tmp_path): @@ -256,28 +248,6 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): # Generic requires --commands-dir via --integration-options assert result.exit_code != 0 - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the generic integration.""" - import yaml - 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", - "--integration-options=--commands-dir .myagent/commands", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - assert ext_cfg.get("context_file") == "AGENTS.md" def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script sh.""" @@ -302,7 +272,6 @@ def test_complete_file_inventory_sh(self, tmp_path): for p in project.rglob("*") if p.is_file() and ".git" not in p.parts ) expected = sorted([ - "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -313,14 +282,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".myagent/commands/speckit.specify.md", ".myagent/commands/speckit.tasks.md", ".myagent/commands/speckit.taskstoissues.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", @@ -367,7 +328,6 @@ def test_complete_file_inventory_ps(self, tmp_path): for p in project.rglob("*") if p.is_file() and ".git" not in p.parts ) expected = sorted([ - "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -378,14 +338,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".myagent/commands/speckit.specify.md", ".myagent/commands/speckit.tasks.md", ".myagent/commands/speckit.taskstoissues.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py index 8415081d53..104b7188d0 100644 --- a/tests/integrations/test_integration_goose.py +++ b/tests/integrations/test_integration_goose.py @@ -12,7 +12,6 @@ class TestGooseIntegration(YamlIntegrationTests): 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 diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py index 89e74c2b38..521a310cb8 100644 --- a/tests/integrations/test_integration_hermes.py +++ b/tests/integrations/test_integration_hermes.py @@ -30,7 +30,6 @@ class TestHermesIntegration(SkillsIntegrationTests): FOLDER = ".hermes/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = "~/.hermes/skills" - CONTEXT_FILE = "AGENTS.md" # -- Hermes-specific setup: skills go to ~/.hermes/skills/ ------------- @@ -72,23 +71,19 @@ def test_setup_writes_to_correct_directory(self, tmp_path, monkeypatch): """Override: Hermes writes to global, not project-local.""" self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch) - def test_plan_references_correct_context_file(self, tmp_path, monkeypatch): - """Plan skill goes to global dir, but we check it still references AGENTS.md.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path, monkeypatch): + """The core plan skill must not carry a context-file placeholder β€” + agent context files are owned by the opt-in agent-context extension.""" home = _fake_home(tmp_path) monkeypatch.setattr(Path, "home", lambda: home) i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) # Find the plan skill in global ~/.hermes/skills/ plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md" assert plan_file.exists(), f"Plan skill {plan_file} not created globally" 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" ) diff --git a/tests/integrations/test_integration_iflow.py b/tests/integrations/test_integration_iflow.py index ea2f5ef97a..89501f8edf 100644 --- a/tests/integrations/test_integration_iflow.py +++ b/tests/integrations/test_integration_iflow.py @@ -8,4 +8,3 @@ class TestIflowIntegration(MarkdownIntegrationTests): FOLDER = ".iflow/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".iflow/commands" - CONTEXT_FILE = "IFLOW.md" diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index 2b924ce434..2226e3d544 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -8,4 +8,3 @@ class TestJunieIntegration(MarkdownIntegrationTests): FOLDER = ".junie/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".junie/commands" - CONTEXT_FILE = ".junie/AGENTS.md" diff --git a/tests/integrations/test_integration_kilocode.py b/tests/integrations/test_integration_kilocode.py index 8e441c0833..86e6520a50 100644 --- a/tests/integrations/test_integration_kilocode.py +++ b/tests/integrations/test_integration_kilocode.py @@ -8,4 +8,3 @@ class TestKilocodeIntegration(MarkdownIntegrationTests): FOLDER = ".kilocode/" COMMANDS_SUBDIR = "workflows" REGISTRAR_DIR = ".kilocode/workflows" - CONTEXT_FILE = ".kilocode/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 112baf0301..48e4daa553 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -1,18 +1,40 @@ """Tests for KimiIntegration β€” skills integration with legacy migration.""" +from pathlib import Path + +import pytest + from specify_cli.integrations import get_integration -from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills +from specify_cli.integrations.kimi import ( + _migrate_legacy_kimi_dotted_skills, + _migrate_legacy_kimi_skills_dir, +) from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_skills import SkillsIntegrationTests +def _symlink_or_skip( + link: Path, target: Path, *, target_is_directory: bool = False +) -> None: + """Create *link* pointing at *target*, skipping the test if unsupported. + + Symlink creation fails on Windows without the create-symlink privilege and + in some restricted CI sandboxes. The symlink-safety tests below assert + behavior that only matters when symlinks exist, so skip (rather than error) + when the platform cannot create them. + """ + try: + link.symlink_to(target, target_is_directory=target_is_directory) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + class TestKimiIntegration(SkillsIntegrationTests): KEY = "kimi" - FOLDER = ".kimi/" + FOLDER = ".kimi-code/" COMMANDS_SUBDIR = "skills" - REGISTRAR_DIR = ".kimi/skills" - CONTEXT_FILE = "KIMI.md" + REGISTRAR_DIR = ".kimi-code/skills" class TestKimiOptions: @@ -103,12 +125,32 @@ def test_nonexistent_dir_returns_zeros(self, tmp_path): assert migrated == 0 assert removed == 0 + def test_setup_migrate_legacy_moves_old_skills_dir(self, tmp_path): + """--migrate-legacy moves hyphenated skills from .kimi/skills to .kimi-code/skills.""" + i = get_integration("kimi") + + old_skills_dir = tmp_path / ".kimi" / "skills" + new_skills_dir = tmp_path / ".kimi-code" / "skills" + legacy = old_skills_dir / "speckit-oldcmd" + legacy.mkdir(parents=True) + (legacy / "SKILL.md").write_text("# Legacy\n") + + m = IntegrationManifest("kimi", tmp_path) + i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) + + assert not legacy.exists() + assert not old_skills_dir.exists() + assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + # New skills from templates should also exist + assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists() + def test_setup_with_migrate_legacy_option(self, tmp_path): """KimiIntegration.setup() with --migrate-legacy migrates dotted dirs.""" i = get_integration("kimi") - skills_dir = tmp_path / ".kimi" / "skills" - legacy = skills_dir / "speckit.oldcmd" + old_skills_dir = tmp_path / ".kimi" / "skills" + new_skills_dir = tmp_path / ".kimi-code" / "skills" + legacy = old_skills_dir / "speckit.oldcmd" legacy.mkdir(parents=True) (legacy / "SKILL.md").write_text("# Legacy\n") @@ -116,10 +158,205 @@ def test_setup_with_migrate_legacy_option(self, tmp_path): i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) assert not legacy.exists() - assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists() + assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists() # New skills from templates should also exist - assert (skills_dir / "speckit-specify" / "SKILL.md").exists() + assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists() + + +class TestKimiTeardownLegacyCleanup: + """teardown() removes leftover legacy .kimi/skills/ directories.""" + + def test_teardown_removes_legacy_speckit_skills(self, tmp_path): + i = get_integration("kimi") + + legacy_skill = tmp_path / ".kimi" / "skills" / "speckit-plan" / "SKILL.md" + legacy_skill.parent.mkdir(parents=True) + legacy_skill.write_text( + "---\n" + "name: \"speckit-plan\"\n" + "description: \"Plan workflow\"\n" + "metadata:\n" + " author: \"github-spec-kit\"\n" + " source: \"templates/commands/plan.md\"\n" + "---\n" + ) + + m = IntegrationManifest("kimi", tmp_path) + i.teardown(tmp_path, m) + + assert not legacy_skill.exists() + assert not (tmp_path / ".kimi" / "skills").exists() + + def test_teardown_preserves_user_skills_in_legacy_dir(self, tmp_path): + i = get_integration("kimi") + + user_skill = tmp_path / ".kimi" / "skills" / "my-custom" / "SKILL.md" + user_skill.parent.mkdir(parents=True) + user_skill.write_text("# My custom skill\n") + + m = IntegrationManifest("kimi", tmp_path) + i.teardown(tmp_path, m) + + assert user_skill.exists() + + +class TestKimiCommandInvocation: + """Kimi dispatch must use the native ``/skill:`` slash command.""" + + def test_build_command_invocation_uses_skill_prefix(self): + i = get_integration("kimi") + assert i.build_command_invocation("specify") == "/skill:speckit-specify" + assert i.build_command_invocation("speckit.plan") == "/skill:speckit-plan" + + def test_build_command_invocation_dotted_extension(self): + i = get_integration("kimi") + assert ( + i.build_command_invocation("speckit.git.commit") + == "/skill:speckit-git-commit" + ) + + def test_build_command_invocation_appends_args(self): + i = get_integration("kimi") + assert ( + i.build_command_invocation("specify", "my feature") + == "/skill:speckit-specify my feature" + ) + + +class TestKimiLegacySymlinkSafety: + """Legacy migration/cleanup must not follow symlinks out of the project.""" + + def test_migrate_skips_symlinked_legacy_skills_dir(self, tmp_path): + # An attacker-controlled directory outside the project root. Use a + # non-template skill name so a successful migration would be visible + # (the bundled templates never create "speckit-evillegacy"). + outside = tmp_path / "outside" + (outside / "speckit-evillegacy").mkdir(parents=True) + (outside / "speckit-evillegacy" / "SKILL.md").write_text("# evil\n") + + project = tmp_path / "project" + (project / ".kimi").mkdir(parents=True) + # .kimi/skills is a symlink to the outside directory. + _symlink_or_skip( + project / ".kimi" / "skills", outside, target_is_directory=True + ) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.setup(project, m, parsed_options={"migrate_legacy": True}) + + # Outside content must be untouched (not moved into .kimi-code). + assert (outside / "speckit-evillegacy" / "SKILL.md").exists() + assert not ( + project / ".kimi-code" / "skills" / "speckit-evillegacy" + ).exists() + + def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path): + outside = tmp_path / "outside" + outside.mkdir() + keep = outside / "keep.txt" + keep.write_text("important\n") + + project = tmp_path / "project" + (project / ".kimi").mkdir(parents=True) + _symlink_or_skip( + project / ".kimi" / "skills", outside, target_is_directory=True + ) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.teardown(project, m) + + # The symlink target and its contents must survive teardown. + assert keep.exists() + + def test_migrate_skips_symlinked_legacy_parent_dir(self, tmp_path): + # `.kimi` is itself a symlink to the project root, so `.kimi/skills` + # resolves to `./skills` β€” an unrelated in-tree directory. Even though + # the resolved path stays inside the project, migration must not + # operate on it because a path component is a symlink. + project = tmp_path / "project" + unrelated = project / "skills" / "speckit-evillegacy" + unrelated.mkdir(parents=True) + (unrelated / "SKILL.md").write_text("# unrelated\n") + # .kimi -> project root, so .kimi/skills == ./skills. + _symlink_or_skip(project / ".kimi", project, target_is_directory=True) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.setup(project, m, parsed_options={"migrate_legacy": True}) + + # The unrelated ./skills content must be untouched. + assert (unrelated / "SKILL.md").exists() + assert not ( + project / ".kimi-code" / "skills" / "speckit-evillegacy" + ).exists() + + def test_teardown_skips_symlinked_legacy_parent_dir(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + # Looks Speckit-generated, so only the symlink check protects it. + unrelated = project / "skills" / "speckit-evillegacy" + unrelated.mkdir(parents=True) + (unrelated / "SKILL.md").write_text( + "---\nmetadata:\n author: github-spec-kit\n---\n# x\n" + ) + _symlink_or_skip(project / ".kimi", project, target_is_directory=True) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + i.teardown(project, m) + + # The unrelated ./skills content must survive teardown. + assert (unrelated / "SKILL.md").exists() + + def test_setup_rejects_symlinked_destination_before_writing(self, tmp_path): + # `.kimi-code` is a symlink to the project root, so the skills + # destination `.kimi-code/skills` resolves to `./skills` β€” an + # unintended in-tree location. base setup() only rejects a + # destination that escapes the project root, so without the + # pre-check it would write SKILL.md files into `./skills`. setup() + # must refuse before any write occurs. + project = tmp_path / "project" + project.mkdir() + _symlink_or_skip(project / ".kimi-code", project, target_is_directory=True) + + i = get_integration("kimi") + m = IntegrationManifest("kimi", project) + with pytest.raises(ValueError, match="symlinked"): + i.setup(project, m) + + # Nothing was written into the unintended `./skills` location. + assert not (project / "skills").exists() + + def test_migrate_skips_symlinked_target_dir(self, tmp_path): + # The destination `.kimi-code/skills/speckit-foo` already exists but is + # a symlink to a directory outside the project. Migration compares + # SKILL.md bytes to decide whether to drop the legacy copy; it must not + # follow the symlinked target dir to read SKILL.md from outside. + outside = tmp_path / "outside" + outside.mkdir() + (outside / "SKILL.md").write_text("# shared\n") + + project = tmp_path / "project" + legacy = project / ".kimi" / "skills" / "speckit-foo" + legacy.mkdir(parents=True) + # Identical bytes: without the symlink guard the legacy dir would be + # removed after following the link out of the project. + (legacy / "SKILL.md").write_text("# shared\n") + + target = project / ".kimi-code" / "skills" / "speckit-foo" + target.parent.mkdir(parents=True) + _symlink_or_skip(target, outside, target_is_directory=True) + + _migrate_legacy_kimi_skills_dir( + project / ".kimi" / "skills", project / ".kimi-code" / "skills" + ) + # Legacy copy is preserved (migration refused to follow the symlink), + # and the outside target is untouched. + assert (legacy / "SKILL.md").exists() + assert (outside / "SKILL.md").exists() class TestKimiNextSteps: """CLI output tests for kimi next-steps display.""" diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index c1a029a55f..29adb0a4a6 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -41,7 +41,6 @@ class TestKiroCliIntegration(MarkdownIntegrationTests): FOLDER = ".kiro/" COMMANDS_SUBDIR = "prompts" REGISTRAR_DIR = ".kiro/prompts" - CONTEXT_FILE = "AGENTS.md" def test_registrar_config(self): """Override base assertion: kiro-cli uses a prose fallback for args diff --git a/tests/integrations/test_integration_lingma.py b/tests/integrations/test_integration_lingma.py index 959de8d657..e3d338d540 100644 --- a/tests/integrations/test_integration_lingma.py +++ b/tests/integrations/test_integration_lingma.py @@ -8,4 +8,3 @@ class TestLingmaIntegration(SkillsIntegrationTests): FOLDER = ".lingma/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".lingma/skills" - CONTEXT_FILE = ".lingma/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_omp.py b/tests/integrations/test_integration_omp.py new file mode 100644 index 0000000000..5b30b76075 --- /dev/null +++ b/tests/integrations/test_integration_omp.py @@ -0,0 +1,30 @@ +"""Tests for OmpIntegration.""" + +from specify_cli.integrations import get_integration + +from .test_integration_base_markdown import MarkdownIntegrationTests + + +class TestOmpIntegration(MarkdownIntegrationTests): + KEY = "omp" + FOLDER = ".omp/" + COMMANDS_SUBDIR = "commands" + REGISTRAR_DIR = ".omp/commands" + + def test_build_exec_args_uses_omp_json_mode(self): + i = get_integration(self.KEY) + + args = i.build_exec_args( + "/speckit.specify Build auth", + model="gpt-5", + ) + + assert args == [ + "omp", + "--print", + "--model", + "gpt-5", + "--mode", + "json", + "/speckit.specify Build auth", + ] diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index ba2d15711f..b9464fdea3 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -14,7 +14,6 @@ class TestOpencodeIntegration(MarkdownIntegrationTests): FOLDER = ".opencode/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".opencode/commands" - CONTEXT_FILE = "AGENTS.md" def test_build_exec_args_uses_run_command_dispatch(self): integration = get_integration(self.KEY) diff --git a/tests/integrations/test_integration_pi.py b/tests/integrations/test_integration_pi.py index 5ac5676501..5dde4a4294 100644 --- a/tests/integrations/test_integration_pi.py +++ b/tests/integrations/test_integration_pi.py @@ -8,4 +8,3 @@ class TestPiIntegration(MarkdownIntegrationTests): FOLDER = ".pi/" COMMANDS_SUBDIR = "prompts" REGISTRAR_DIR = ".pi/prompts" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py index 1dbee480a0..29a6d16d29 100644 --- a/tests/integrations/test_integration_qodercli.py +++ b/tests/integrations/test_integration_qodercli.py @@ -8,4 +8,3 @@ class TestQodercliIntegration(MarkdownIntegrationTests): FOLDER = ".qoder/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".qoder/commands" - CONTEXT_FILE = "QODER.md" diff --git a/tests/integrations/test_integration_qwen.py b/tests/integrations/test_integration_qwen.py index 10a3c083f4..3de85d3888 100644 --- a/tests/integrations/test_integration_qwen.py +++ b/tests/integrations/test_integration_qwen.py @@ -8,4 +8,3 @@ class TestQwenIntegration(MarkdownIntegrationTests): FOLDER = ".qwen/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".qwen/commands" - CONTEXT_FILE = "QWEN.md" diff --git a/tests/integrations/test_integration_roo.py b/tests/integrations/test_integration_roo.py index 69d859c42f..b713f96362 100644 --- a/tests/integrations/test_integration_roo.py +++ b/tests/integrations/test_integration_roo.py @@ -8,4 +8,3 @@ class TestRooIntegration(MarkdownIntegrationTests): FOLDER = ".roo/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".roo/commands" - CONTEXT_FILE = ".roo/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_rovodev.py b/tests/integrations/test_integration_rovodev.py index 8e992476fb..5bdafc25f9 100644 --- a/tests/integrations/test_integration_rovodev.py +++ b/tests/integrations/test_integration_rovodev.py @@ -52,7 +52,6 @@ class TestRovodevIntegration: which violates the base mixin's pure-skills assumptions).""" KEY = "rovodev" - CONTEXT_FILE = "AGENTS.md" # -- ACLI dispatch ----------------------------------------------------- @@ -218,12 +217,8 @@ def test_init_inventory(self, rovodev_init_project): # Prompts: exactly the core template set. assert prompt_stems == core_skill_names - # Skills: core βˆͺ extension-installed. - assert core_skill_names.issubset(skill_names) - extension_skills = skill_names - core_skill_names - assert extension_skills, ( - "Expected at least one extension-installed skill (e.g. agent-context)" - ) + # Skills: exactly the core template set (no extension auto-install). + assert skill_names == core_skill_names # prompts.yml mirrors the prompt files exactly. prompts_manifest = project / ".rovodev" / "prompts.yml" @@ -266,10 +261,6 @@ def test_init_skill_files_well_formed(self, rovodev_init_project): f"{skill_file} body contains dot-notation /speckit. reference" ) - # The plan skill must reference the agent's context file. - plan_content = (skills_dir / "speckit-plan" / "SKILL.md").read_text(encoding="utf-8") - assert self.CONTEXT_FILE in plan_content - # -- Full-CLI init: integration metadata ------------------------------- def test_init_writes_integration_manifest_and_options(self, rovodev_init_project): diff --git a/tests/integrations/test_integration_shai.py b/tests/integrations/test_integration_shai.py index 74f93396b1..fc2b60c3f2 100644 --- a/tests/integrations/test_integration_shai.py +++ b/tests/integrations/test_integration_shai.py @@ -8,4 +8,3 @@ class TestShaiIntegration(MarkdownIntegrationTests): FOLDER = ".shai/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".shai/commands" - CONTEXT_FILE = "SHAI.md" diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index c3ebb9773d..34114a564e 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1812,7 +1812,7 @@ def test_switch_migrates_extension_commands(self, tmp_path): 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" + kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md" assert kimi_git_feature.exists(), "Git extension skill should exist for kimi" result = _run_in_project(project, [ diff --git a/tests/integrations/test_integration_tabnine.py b/tests/integrations/test_integration_tabnine.py index 95eb47cc16..71bf398862 100644 --- a/tests/integrations/test_integration_tabnine.py +++ b/tests/integrations/test_integration_tabnine.py @@ -8,4 +8,3 @@ class TestTabnineIntegration(TomlIntegrationTests): FOLDER = ".tabnine/agent/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".tabnine/agent/commands" - CONTEXT_FILE = "TABNINE.md" diff --git a/tests/integrations/test_integration_trae.py b/tests/integrations/test_integration_trae.py index 74b8b41c3f..2805263b3d 100644 --- a/tests/integrations/test_integration_trae.py +++ b/tests/integrations/test_integration_trae.py @@ -8,4 +8,3 @@ class TestTraeIntegration(SkillsIntegrationTests): FOLDER = ".trae/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".trae/skills" - CONTEXT_FILE = ".trae/rules/project_rules.md" diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index bab4539f1e..98c9fdf06d 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -13,7 +13,6 @@ class TestVibeIntegration(SkillsIntegrationTests): FOLDER = ".vibe/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".vibe/skills" - CONTEXT_FILE = "AGENTS.md" class TestVibeUserInvocable: diff --git a/tests/integrations/test_integration_windsurf.py b/tests/integrations/test_integration_windsurf.py index fa8d1e622a..4cdfaa94a3 100644 --- a/tests/integrations/test_integration_windsurf.py +++ b/tests/integrations/test_integration_windsurf.py @@ -8,4 +8,3 @@ class TestWindsurfIntegration(MarkdownIntegrationTests): FOLDER = ".windsurf/" COMMANDS_SUBDIR = "workflows" REGISTRAR_DIR = ".windsurf/workflows" - CONTEXT_FILE = ".windsurf/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_zcode.py b/tests/integrations/test_integration_zcode.py index 3eb82ed4f2..f431d3e4a0 100644 --- a/tests/integrations/test_integration_zcode.py +++ b/tests/integrations/test_integration_zcode.py @@ -8,7 +8,6 @@ class TestZcodeIntegration(SkillsIntegrationTests): FOLDER = ".zcode/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".zcode/skills" - CONTEXT_FILE = "ZCODE.md" class TestZcodeInvocation: diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index 0172e6b275..739fdbf23b 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -14,7 +14,6 @@ class TestZedIntegration(SkillsIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".agents/skills" - CONTEXT_FILE = "AGENTS.md" def test_options_include_skills_flag(self): """Not applicable to Zed β€” Zed is always skills-based with no --skills flag.""" diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 0110e19ec7..be27875264 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -48,6 +48,19 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]: ] +def _multi_install_safe_orders() -> list[list[str]]: + safe_keys = _multi_install_safe_keys() + if len(safe_keys) < 2: + return [safe_keys] + return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))] + + +def _multi_install_safe_order_id(ordered_keys: list[str]) -> str: + if not ordered_keys: + return "no-safe-integrations" + return f"init-{ordered_keys[0]}" + + def _posix_path(value: str | None) -> str | None: if not value: return None @@ -87,16 +100,6 @@ def _paths_overlap(first: str | None, second: str | None) -> bool: 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) @@ -164,17 +167,12 @@ class TestMultiInstallSafeContracts: @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): @@ -192,98 +190,75 @@ def test_safe_integrations_have_distinct_command_dirs(self, first, second): 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()) + @pytest.mark.parametrize( + "ordered_keys", + _multi_install_safe_orders(), + ids=_multi_install_safe_order_id, + ) def test_safe_integrations_have_disjoint_manifests( self, tmp_path, - first, - second, + ordered_keys, ): - 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", - "--ignore-agent-tools", - ], - catch_exceptions=False, - ) - assert init_result.exit_code == 0, init_result.output + # The pairwise disjointness contract is only meaningful with at least + # two safe integrations. Guard so a shrunken registry fails loudly here + # rather than passing vacuously (or tripping over ordered_keys[0] below). + assert len(ordered_keys) >= 2, ( + f"expected at least two multi-install-safe integrations, got {ordered_keys}" + ) + + project_root = tmp_path / "project" + project_root.mkdir() + runner = CliRunner() + + # Install every safe integration once into a single project, then assert + # pairwise manifest isolation. Each safe integration writes only to its + # own (disjoint) directories and always records what it writes, so a + # manifest's contents are independent of install order and of which other + # integrations are co-installed. The parametrized rotations keep the + # aggregate setup while placing each safe integration first once, so each + # one still exercises the `specify init --integration ...` path. + original_cwd = os.getcwd() + try: + os.chdir(project_root) + init_result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + ordered_keys[0], + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + assert init_result.exit_code == 0, init_result.output + for key in ordered_keys[1:]: install_result = runner.invoke( app, - ["integration", "install", additional, "--script", "sh"], + ["integration", "install", key, "--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") + finally: + os.chdir(original_cwd) + + integrations_dir = project_root / ".specify" / "integrations" + manifests = { + key: set( + json.loads( + (integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8") + ).get("files", {}) ) - - 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)}" + for key in ordered_keys + } + + for first, second in _multi_install_safe_pairs(): + overlap = manifests[first] & manifests[second] + assert not overlap, ( + f"{first} and {second} are declared multi-install safe but both manage " + f"these files: {sorted(overlap)}" ) diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 49e74ef5ef..94496af5ef 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -34,6 +34,7 @@ "kiro-cli", "lingma", "vibe", + "omp", "opencode", "pi", "qodercli", @@ -225,17 +226,17 @@ def test_agent_config_includes_tabnine(self): def test_kimi_in_agent_config(self): """AGENT_CONFIG should include kimi with correct folder and commands_subdir.""" assert "kimi" in AGENT_CONFIG - assert AGENT_CONFIG["kimi"]["folder"] == ".kimi/" + assert AGENT_CONFIG["kimi"]["folder"] == ".kimi-code/" assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills" assert AGENT_CONFIG["kimi"]["requires_cli"] is True def test_kimi_in_extension_registrar(self): - """Extension command registrar should include kimi using .kimi/skills and SKILL.md.""" + """Extension command registrar should include kimi using .kimi-code/skills and SKILL.md.""" cfg = CommandRegistrar.AGENT_CONFIGS assert "kimi" in cfg kimi_cfg = cfg["kimi"] - assert kimi_cfg["dir"] == ".kimi/skills" + assert kimi_cfg["dir"] == ".kimi-code/skills" assert kimi_cfg["extension"] == "/SKILL.md" def test_agent_config_includes_kimi(self): diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 8b09245384..a89303d3d8 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -900,3 +900,45 @@ def test_accept_header_present(self, monkeypatch): 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" + + +# --------------------------------------------------------------------------- +# github_provider_hosts +# --------------------------------------------------------------------------- + + +class TestGithubProviderHosts: + """Tests for github_provider_hosts() β€” the GHES host allowlist source.""" + + def _set_config(self, monkeypatch, entries): + from specify_cli.authentication import http as _auth_http + monkeypatch.setattr(_auth_http, "_config_override", entries) + + def test_returns_hosts_from_github_entries(self, monkeypatch): + from specify_cli.authentication.http import github_provider_hosts + self._set_config(monkeypatch, [ + AuthConfigEntry(hosts=("ghes.example", "raw.ghes.example"), + provider="github", auth="bearer", token="t"), + ]) + assert github_provider_hosts() == ("ghes.example", "raw.ghes.example") + + def test_empty_when_no_config(self, monkeypatch): + from specify_cli.authentication.http import github_provider_hosts + self._set_config(monkeypatch, []) + assert github_provider_hosts() == () + + def test_ignores_non_github_providers(self, monkeypatch): + from specify_cli.authentication.http import github_provider_hosts + self._set_config(monkeypatch, [ + AuthConfigEntry(hosts=("dev.azure.com",), provider="azure-devops", + auth="basic-pat", token="t"), + ]) + assert github_provider_hosts() == () + + def test_unions_multiple_github_entries(self, monkeypatch): + from specify_cli.authentication.http import github_provider_hosts + self._set_config(monkeypatch, [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + AuthConfigEntry(hosts=("github.com",), provider="github", auth="bearer", token="t"), + ]) + assert github_provider_hosts() == ("ghes.example", "github.com") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 4cd052fd81..6260ad6abf 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -16,8 +16,10 @@ import tempfile import shutil import tomllib +from contextlib import contextmanager from pathlib import Path from datetime import datetime, timezone +from unittest.mock import MagicMock from tests.conftest import strip_ansi from specify_cli.extensions import ( @@ -38,6 +40,10 @@ version_satisfies, ) +# Minimal valid ZIP (empty end-of-central-directory record). Passes +# zipfile.is_zipfile() so --from download tests exercise the content guard. +_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18 + def can_create_symlink(tmp_path: Path) -> bool: """Return True when the current platform/user can create file symlinks.""" @@ -1669,6 +1675,47 @@ def test_render_toml_command_preserves_multiline_description(self): assert parsed["description"] == "first line\nsecond line\n" + def test_render_toml_command_preserves_backslashes_in_body(self): + """A backslash in the body (e.g. a Windows path) must not break TOML. + + A multiline basic string ("\"\"\"") processes backslash escapes, so + ``C:\\Users`` (``\\U``) would render as invalid TOML; the body must + round-trip with backslashes intact. + """ + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": "x"}, + r"Run C:\Users\dev\tool.exe then report.", + "extension:test-ext", + ) + parsed = tomllib.loads(output) # must not raise + assert parsed["prompt"].strip() == r"Run C:\Users\dev\tool.exe then report." + + def test_render_toml_command_handles_trailing_backslash(self): + """A body ending in a backslash must round-trip without corruption.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + registrar = AgentCommandRegistrar() + output = registrar.render_toml_command( + {"description": "x"}, + "path ends with sep\\", + "extension:test-ext", + ) + parsed = tomllib.loads(output) + assert parsed["prompt"].strip() == "path ends with sep\\" + + def test_render_toml_command_backslash_with_both_triple_quotes_escapes(self): + """Body with a backslash and both triple-quote styles β†’ escaped basic string.""" + from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar + + registrar = AgentCommandRegistrar() + body = "a \\ b\nc \"\"\" d\ne ''' f" + output = registrar.render_toml_command({"description": "x"}, body, "extension:test-ext") + parsed = tomllib.loads(output) + assert parsed["prompt"] == body + def test_register_commands_for_claude(self, extension_dir, project_dir): """Test registering commands for Claude agent.""" # Create .claude directory @@ -1896,7 +1943,7 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir @pytest.mark.parametrize("agent_name,skills_path", [ ("codex", ".agents/skills"), - ("kimi", ".kimi/skills"), + ("kimi", ".kimi-code/skills"), ("claude", ".claude/skills"), ("cursor-agent", ".cursor/skills"), ("trae", ".trae/skills"), @@ -3760,6 +3807,89 @@ def fake_open(req, timeout=None): assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken" assert captured[1].get_header("Accept") == "application/octet-stream" + def _make_zip_bytes(self): + """Build a minimal valid extension ZIP in memory for download tests.""" + import zipfile + import io + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n") + return buf.getvalue() + + def _mock_response(self, data): + """Build a context-manager mock HTTP response returning ``data``.""" + from unittest.mock import MagicMock + + resp = MagicMock() + resp.read.return_value = data + # Configure the context-manager protocol explicitly so `with resp` + # yields `resp` itself, independent of how the protocol is invoked. + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return resp + + def test_download_extension_accepts_matching_sha256(self, temp_dir): + """A catalog ``sha256`` that matches the archive is accepted.""" + import hashlib + from unittest.mock import patch + + catalog = self._make_catalog(temp_dir) + zip_bytes = self._make_zip_bytes() + ext_info = { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "download_url": "https://example.com/test-ext.zip", + "sha256": hashlib.sha256(zip_bytes).hexdigest(), + } + + with patch.object(catalog, "get_extension_info", return_value=ext_info), \ + patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)): + zip_path = catalog.download_extension("test-ext", target_dir=temp_dir) + + assert zip_path.read_bytes() == zip_bytes + + def test_download_extension_rejects_sha256_mismatch(self, temp_dir): + """A catalog ``sha256`` that does not match the downloaded archive + aborts the install β€” a tampered or swapped archive is rejected. + """ + from unittest.mock import patch + + catalog = self._make_catalog(temp_dir) + zip_bytes = self._make_zip_bytes() + ext_info = { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "download_url": "https://example.com/test-ext.zip", + "sha256": "0" * 64, # deliberately wrong + } + + with patch.object(catalog, "get_extension_info", return_value=ext_info), \ + patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)): + with pytest.raises(ExtensionError, match="[Ii]ntegrity"): + catalog.download_extension("test-ext", target_dir=temp_dir) + + def test_download_extension_without_sha256_still_succeeds(self, temp_dir): + """Entries without ``sha256`` keep working (backwards compatible).""" + from unittest.mock import patch + + catalog = self._make_catalog(temp_dir) + zip_bytes = self._make_zip_bytes() + ext_info = { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "download_url": "https://example.com/test-ext.zip", + } + + with patch.object(catalog, "get_extension_info", return_value=ext_info), \ + patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)): + zip_path = catalog.download_extension("test-ext", target_dir=temp_dir) + + assert zip_path.read_bytes() == zip_bytes + def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch): """download_extension can use a GitHub REST release asset URL directly.""" from unittest.mock import patch, MagicMock @@ -5252,7 +5382,7 @@ def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, forc runner = CliRunner() with patch.object(Path, "cwd", return_value=project_dir), \ patch("typer.confirm", return_value=True), \ - patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(b"zip-bytes")), \ + patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \ patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \ patch.object(ExtensionRegistry, "get", return_value={}): result = runner.invoke( @@ -5320,6 +5450,98 @@ def test_add_from_url_escapes_download_exception_markup(self, tmp_path): assert "https://example.com/[red]ext[/red].zip" in result.output assert "bad [red]download[/red]" in result.output + def test_add_from_url_rejects_non_zip_login_page(self, tmp_path): + """An HTML login page (unauthenticated fetch) must fail clearly, not BadZipFile.""" + import io + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch( + "specify_cli.authentication.http.open_url", + return_value=FakeResponse(b"Sign in"), + ), \ + patch.object(ExtensionManager, "install_from_zip") as install: + result = runner.invoke( + app, + ["extension", "add", "my-ext", "--from", "https://raw.ghe.example/o/r/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 1, result.output + assert "did not return a ZIP archive" in result.output + install.assert_not_called() + + def test_add_from_url_resolves_ghes_release_asset(self, tmp_path): + """A GHES release-download URL resolves to /api/v3 with octet-stream Accept.""" + import io + from types import SimpleNamespace + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + import json + + class FakeResponse(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + seen = {} + + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + if "/releases/tags/" in url: + body = json.dumps({ + "assets": [{ + "name": "ext.zip", + "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42", + }] + }).encode() + return FakeResponse(body) + seen["url"] = url + seen["headers"] = extra_headers + return FakeResponse(_MINIMAL_ZIP_BYTES) + + def fake_install(self_obj, zip_path, speckit_version, priority=10, force=False): + return SimpleNamespace( + id="x", name="X", version="1.0.0", description="", warnings=[], commands=[], hooks=[] + ) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("typer.confirm", return_value=True), \ + patch("specify_cli.authentication.http.github_provider_hosts", return_value=("ghes.example",)), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \ + patch.object(ExtensionManager, "install_from_zip", fake_install): + result = runner.invoke( + app, + ["extension", "add", "x", "--from", + "https://ghes.example/org/repo/releases/download/v1.0/ext.zip"], + catch_exceptions=True, + ) + + assert result.exit_code == 0, result.output + assert "/api/v3/repos/org/repo/releases/assets/" in seen["url"] + assert seen["headers"] == {"Accept": "application/octet-stream"} + @pytest.mark.parametrize( ("exc_type", "label"), [ @@ -5397,7 +5619,7 @@ def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, forc runner = CliRunner() with patch.object(Path, "cwd", return_value=project_dir), \ patch("typer.confirm", return_value=True), \ - patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(b"zip-bytes")), \ + patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \ patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip): result = runner.invoke( app, @@ -5406,7 +5628,7 @@ def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, forc ) assert result.exit_code == 0 - assert installed["zip_bytes"] == b"zip-bytes" + assert installed["zip_bytes"] == _MINIMAL_ZIP_BYTES assert installed["zip_path"].resolve().is_relative_to(downloads_dir.resolve()) assert installed["zip_path"].name.startswith("extension-url-download-") assert not installed["zip_path"].exists() @@ -7156,3 +7378,36 @@ def test_add_dev_force_reinstall(self, tmp_path): ) assert result2.exit_code == 0, strip_ansi(result2.output) assert "installed" in strip_ansi(result2.output) + + +def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch): + """End-to-end wiring: auth.json github host β†’ GHES asset resolution.""" + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + from specify_cli.extensions import ExtensionCatalog + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", + auth="bearer", token="t"), + ]) + catalog = ExtensionCatalog(tmp_path) + + captured = [] + + @contextmanager + def fake_open(url, timeout=None, extra_headers=None): + captured.append(url) + resp = MagicMock() + resp.read.return_value = json.dumps({ + "assets": [{"name": "ext.zip", + "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}] + }).encode() + yield resp + + monkeypatch.setattr(catalog, "_open_url", fake_open) + + resolved = catalog._resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v1/ext.zip" + ) + assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7" + assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"] diff --git a/tests/test_github_http.py b/tests/test_github_http.py index e258f4917f..cd1b651aaa 100644 --- a/tests/test_github_http.py +++ b/tests/test_github_http.py @@ -188,3 +188,117 @@ def capturing_open(url, timeout=None, extra_headers=None): ) assert len(captured_urls) == 1 assert "releases/tags/v1%23beta" in captured_urls[0] + + # --- GHES (GitHub Enterprise Server) --- + + def test_resolves_ghes_browser_url_to_api_url(self): + """A GHES browser release URL resolves to the /api/v3 asset URL.""" + release_json = { + "assets": [ + {"name": "ext.zip", + "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"} + ] + } + result = resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v1/ext.zip", + self._make_open_url_fn(release_json), + github_hosts=("ghes.example",), + ) + assert result == "https://ghes.example/api/v3/repos/o/r/releases/assets/7" + + def test_passthrough_for_existing_ghes_api_asset_url(self): + """An already-resolved GHES /api/v3 asset URL is returned as-is.""" + url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7" + result = resolve_github_release_asset_api_url( + url, lambda *a, **kw: None, github_hosts=("ghes.example",) + ) + assert result == url + + def test_returns_none_for_ghes_host_not_in_allowlist(self): + """Unlisted hosts get no GHES treatment and trigger no API call (anti-SSRF).""" + called = [] + + @contextmanager + def recording_open(url, timeout=None, extra_headers=None): + called.append(url) + resp = MagicMock() + resp.read.return_value = b"{}" + yield resp + + result = resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v1/ext.zip", + recording_open, + github_hosts=("other.example",), + ) + assert result is None + assert called == [] + + def test_passthrough_for_unlisted_ghes_api_asset_url(self): + """A direct GHES /api/v3 asset URL passes through even when the host is + not allowlisted: passthrough issues no API request, and the download + helper gates the token independently, so octet-stream resolution must + not be withheld.""" + called = [] + + @contextmanager + def recording_open(url, timeout=None, extra_headers=None): + called.append(url) + resp = MagicMock() + resp.read.return_value = b"{}" + yield resp + + url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7" + result = resolve_github_release_asset_api_url( + url, recording_open, github_hosts=("other.example",) + ) + assert result == url + assert called == [] + + def test_ghes_api_base_preserves_scheme_and_port(self): + """The GHES API base mirrors the URL scheme and keeps a non-standard port.""" + captured = [] + + @contextmanager + def capturing_open(url, timeout=None, extra_headers=None): + captured.append(url) + resp = MagicMock() + resp.read.return_value = json.dumps({"assets": []}).encode() + yield resp + + resolve_github_release_asset_api_url( + "http://localhost:8000/o/r/releases/download/v1/ext.zip", + capturing_open, + github_hosts=("localhost",), + ) + assert captured == ["http://localhost:8000/api/v3/repos/o/r/releases/tags/v1"] + + def test_ghes_wildcard_does_not_match_bare_host(self): + """A '*.suffix' pattern does not match the bare host (must list it explicitly).""" + result = resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v1/ext.zip", + lambda *a, **kw: None, + github_hosts=("*.ghes.example",), + ) + assert result is None + + def test_public_github_url_unaffected_by_github_hosts(self): + """Public github.com still resolves via api.github.com even with github_hosts set.""" + captured = [] + + @contextmanager + def capturing_open(url, timeout=None, extra_headers=None): + captured.append(url) + resp = MagicMock() + resp.read.return_value = json.dumps({ + "assets": [{"name": "pack.zip", + "url": "https://api.github.com/repos/org/repo/releases/assets/99"}] + }).encode() + yield resp + + result = resolve_github_release_asset_api_url( + "https://github.com/org/repo/releases/download/v1.0/pack.zip", + capturing_open, + github_hosts=("ghes.example",), + ) + assert result == "https://api.github.com/repos/org/repo/releases/assets/99" + assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"] diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py new file mode 100644 index 0000000000..b6ee409fb0 --- /dev/null +++ b/tests/test_github_workflows.py @@ -0,0 +1,41 @@ +"""Static checks for repository GitHub Actions workflows.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" +# Match both the dedicated-step form (` uses: x@sha`) and the +# inline shorthand (` - uses: x@sha`) used in catalog-assign.yml. +USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P\S+)", re.MULTILINE) +PINNED_SHA_RE = re.compile(r"@[0-9a-f]{40}$", re.IGNORECASE) + + +def test_github_actions_are_pinned_to_full_commit_shas(): + unpinned_refs = [] + + workflows = sorted( + list(WORKFLOWS_DIR.glob("*.yml")) + list(WORKFLOWS_DIR.glob("*.yaml")) + ) + assert workflows + + for workflow in workflows: + workflow_text = workflow.read_text(encoding="utf-8") + for match in USES_RE.finditer(workflow_text): + uses_ref = match.group("ref") + if uses_ref.startswith(("./", "../")): + continue + if PINNED_SHA_RE.search(uses_ref): + continue + unpinned_refs.append(f"{workflow.relative_to(REPO_ROOT)}: {uses_ref}") + + assert unpinned_refs == [] + + +def test_pinned_action_ref_accepts_uppercase_hex_sha(): + assert PINNED_SHA_RE.search( + "actions/example@0123456789ABCDEF0123456789ABCDEF01234567" + ) diff --git a/tests/test_presets.py b/tests/test_presets.py index 58574bbc9c..58dcdc7119 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -17,9 +17,11 @@ import shutil import warnings import zipfile +from contextlib import contextmanager from pathlib import Path from datetime import datetime, timezone from types import SimpleNamespace +from unittest.mock import MagicMock import yaml @@ -1422,6 +1424,26 @@ def test_validate_catalog_url_localhost_http_allowed(self, project_dir): catalog._validate_catalog_url("http://localhost:8080/catalog.json") catalog._validate_catalog_url("http://127.0.0.1:8080/catalog.json") + @pytest.mark.parametrize( + "url", + [ + "https://:8080", # port only, no host + "https://:0", # port only, no host + "https://user@", # userinfo only, no host + "https://user:pw@", # userinfo only, no host + ], + ) + def test_validate_catalog_url_hostless_rejected(self, project_dir, url): + """Reject host-less URLs whose netloc is truthy but hostname is None. + + ``urlparse('https://:8080').netloc`` is ``':8080'`` (truthy) but its + ``hostname`` is ``None``, so a netloc-based check would accept a URL + with no actual host, contradicting the "valid URL with a host" error. + """ + catalog = PresetCatalog(project_dir) + with pytest.raises(PresetValidationError, match="valid URL with a host"): + catalog._validate_catalog_url(url) + def test_env_var_catalog_url(self, project_dir, monkeypatch): """Test catalog URL from environment variable.""" monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", "https://custom.example.com/catalog.json") @@ -2019,6 +2041,90 @@ def fake_open(req, timeout=None): assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken" assert captured[1].get_header("Accept") == "application/octet-stream" + def _pack_zip_and_response(self): + """Build a minimal preset ZIP and a context-manager mock response.""" + from unittest.mock import MagicMock + 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() + + resp = MagicMock() + resp.read.return_value = zip_bytes + # Configure the context-manager protocol explicitly so `with resp` + # yields `resp` itself, independent of how the protocol is invoked. + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return zip_bytes, resp + + def test_download_pack_accepts_matching_sha256(self, project_dir): + """A catalog ``sha256`` that matches the preset archive is accepted.""" + import hashlib + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + zip_bytes, resp = self._pack_zip_and_response() + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "sha256": hashlib.sha256(zip_bytes).hexdigest(), + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=resp): + zip_path = catalog.download_pack("test-pack", target_dir=project_dir) + + assert zip_path.read_bytes() == zip_bytes + + def test_download_pack_rejects_sha256_mismatch(self, project_dir): + """A catalog ``sha256`` that does not match the archive aborts install.""" + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + _zip_bytes, resp = self._pack_zip_and_response() + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "sha256": "0" * 64, # deliberately wrong + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=resp): + with pytest.raises(PresetError, match="[Ii]ntegrity"): + catalog.download_pack("test-pack", target_dir=project_dir) + + def test_download_pack_without_sha256_skips_verification(self, project_dir): + """A catalog entry with no ``sha256`` keeps working: verification is + opt-in, so the backwards-compatible path (``pack_info.get("sha256")`` + is ``None``) must download without aborting β€” mirrors the extensions + coverage so the helper never silently becomes mandatory for presets. + """ + from unittest.mock import patch + + catalog = PresetCatalog(project_dir) + zip_bytes, resp = self._pack_zip_and_response() + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://example.com/test-pack.zip", + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch.object(catalog, "_open_url", return_value=resp): + zip_path = catalog.download_pack("test-pack", target_dir=project_dir) + + assert zip_path.read_bytes() == zip_bytes + def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch): """download_pack can use a GitHub REST release asset URL directly.""" from unittest.mock import patch, MagicMock @@ -3679,12 +3785,16 @@ def test_preset_remove_skips_skill_dir_without_skill_file(self, project_dir, tem assert note_file.read_text(encoding="utf-8") == "user content" def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp_dir): - """Preset overrides should still target legacy dotted Kimi skill directories.""" + """Preset overrides should still target legacy dotted-named skill dirs. + + This exercises legacy *naming* (``speckit.specify``) under the current + ``.kimi-code/`` base β€” distinct from the legacy ``.kimi/`` *location*. + """ self._write_init_options(project_dir, ai="kimi") - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit.specify", body="untouched") - (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) install_self_test_preset(manager) @@ -3701,10 +3811,10 @@ def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi presets should still propagate command overrides to existing skills.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False) - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) install_self_test_preset(manager) @@ -3721,7 +3831,7 @@ def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, temp_dir): """Kimi native skills should still receive brand-new preset commands.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False) - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" skills_dir.mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / "kimi-new-skill" @@ -3770,9 +3880,9 @@ def test_kimi_new_skill_created_even_when_ai_skills_disabled(self, project_dir, def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_dir, temp_dir): """Kimi preset skill overrides should resolve placeholders and rewrite project paths.""" self._write_init_options(project_dir, ai="kimi", ai_skills=False, script="sh") - skills_dir = project_dir / ".kimi" / "skills" + skills_dir = project_dir / ".kimi-code" / "skills" self._create_skill(skills_dir, "speckit-specify", body="untouched") - (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) + (project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True) preset_dir = temp_dir / "kimi-placeholder-override" preset_dir.mkdir() @@ -4664,6 +4774,69 @@ def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42" assert captured_urls[0][1] == {"Accept": "application/octet-stream"} + def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): + """'preset add --from ' resolves via GHES /api/v3 endpoint.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + ]) + + manifest_content = yaml.dump({ + "schema_version": "1.0", + "preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"}, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]}, + }) + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", manifest_content) + zip_bytes = zip_buf.getvalue() + + captured_urls = [] + + class FakeResponse: + def __init__(self, data): + self._data = data + + def read(self): + return self._data + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return FakeResponse(json.dumps({ + "assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}] + }).encode()) + return FakeResponse(zip_bytes) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.get_speckit_version", return_value="1.0.0"), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, [ + "preset", "add", + "--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip", + ]) + + assert result.exit_code == 0, result.output + # The tag-lookup call must use the GHES /api/v3 endpoint + assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls) + # The asset download call must carry Accept: application/octet-stream + asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + class TestWrapStrategy: """Tests for strategy: wrap preset command substitution.""" @@ -5933,3 +6106,36 @@ def _create_pack(temp_dir, valid_pack_data, pack_id, content, (subdir / f"{template_name}.md").write_text(content) return pack_dir + + +def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch): + """End-to-end wiring for presets: auth.json github host β†’ GHES asset resolution.""" + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + from specify_cli.presets import PresetCatalog + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", + auth="bearer", token="t"), + ]) + catalog = PresetCatalog(tmp_path) + + captured = [] + + @contextmanager + def fake_open(url, timeout=None, extra_headers=None): + captured.append(url) + resp = MagicMock() + resp.read.return_value = json.dumps({ + "assets": [{"name": "pack.zip", + "url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}] + }).encode() + yield resp + + monkeypatch.setattr(catalog, "_open_url", fake_open) + + resolved = catalog._resolve_github_release_asset_api_url( + "https://ghes.example/o/r/releases/download/v2/pack.zip" + ) + assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9" + assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"] diff --git a/tests/test_setup_plan_no_overwrite.py b/tests/test_setup_plan_no_overwrite.py index c0db317263..b965551f2d 100644 --- a/tests/test_setup_plan_no_overwrite.py +++ b/tests/test_setup_plan_no_overwrite.py @@ -224,3 +224,49 @@ def test_ps_setup_plan_preserves_existing_plan(plan_repo: Path) -> None: assert "IMPL_PLAN" in data # The skip message should be on stderr assert "already exists" in result.stderr + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_ps_setup_plan_copied_message_on_stderr_in_json_mode(plan_repo: Path) -> None: + """First run in -Json mode must emit 'Copied plan template' on stderr (matching + the bash twin) while keeping stdout pure JSON. Before the fix the PowerShell + script emitted no copy status at all.""" + script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + # stdout stays parseable JSON; the status message goes to stderr. + data = json.loads(result.stdout) + assert "IMPL_PLAN" in data + assert "Copied plan template" in result.stderr + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_ps_setup_plan_template_not_found_warning_matches_bash(plan_repo: Path) -> None: + """When no plan template resolves, -Json mode must emit 'Warning: Plan template + not found' on stderr (matching the bash twin's wording and stream routing) while + keeping stdout pure JSON. Before the fix the PowerShell script used Write-Warning, + producing a different 'WARNING:' prefix on the warning stream instead.""" + # Remove the template the fixture installs so resolution finds nothing. + (plan_repo / ".specify" / "templates" / "plan-template.md").unlink() + script = plan_repo / ".specify" / "scripts" / "powershell" / "setup-plan.ps1" + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=plan_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert "IMPL_PLAN" in data + assert "Warning: Plan template not found" in result.stderr diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py index 0e3fb85f41..47a284f8a0 100644 --- a/tests/test_setup_tasks.py +++ b/tests/test_setup_tasks.py @@ -840,3 +840,54 @@ def test_setup_tasks_ps_errors_without_feature_context( output = result.stderr + result.stdout assert result.returncode != 0 assert "Feature directory not found" in output + + +# --------------------------------------------------------------------------- +# Directory non-emptiness parity: a dir whose only contents are subdirectories +# (e.g. contracts/v1/openapi.yaml) must count as non-empty in both shells. +# --------------------------------------------------------------------------- + +def _run_bash_check_dir(repo: Path, target: Path) -> subprocess.CompletedProcess: + script = repo / ".specify" / "scripts" / "bash" / "common.sh" + return subprocess.run( + ["bash", "-c", 'source "$1"; check_dir "$2" "contracts/"', "bash", str(script), str(target)], + # check_dir echoes the non-ASCII markers βœ“/βœ—; decode UTF-8 explicitly so + # the result does not depend on the platform locale (e.g. cp1252 on Windows). + cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False, env=_clean_env(), + ) + + +def _run_powershell_test_dir(repo: Path, target: Path) -> subprocess.CompletedProcess: + script = repo / ".specify" / "scripts" / "powershell" / "common.ps1" + exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL + return subprocess.run( + [exe, "-NoProfile", "-Command", + '& { param($common, $dir) . $common; Test-DirHasFiles -Path $dir -Description "contracts/" }', + str(script), str(target)], + cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False, env=_clean_env(), + ) + + +@requires_bash +def test_check_dir_bash_counts_subdir_only_contracts(tasks_repo: Path) -> None: + """bash check_dir treats a dir containing only subdirectories as non-empty.""" + contracts = tasks_repo / "contracts" / "v1" + contracts.mkdir(parents=True) + (contracts / "openapi.yaml").write_text("openapi: 3.0\n", encoding="utf-8") + result = _run_bash_check_dir(tasks_repo, tasks_repo / "contracts") + # check_dir always exits 0 (it echoes βœ“/βœ— instead of setting an exit code), + # so the βœ“ marker in stdout β€” not the return code β€” is what proves non-emptiness. + assert "βœ“" in result.stdout and "βœ—" not in result.stdout, result.stderr + result.stdout + + +@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available") +def test_dir_has_files_ps_counts_subdir_only_contracts(tasks_repo: Path) -> None: + """Test-DirHasFiles must match bash: a subdir-only dir counts as non-empty.""" + contracts = tasks_repo / "contracts" / "v1" + contracts.mkdir(parents=True) + (contracts / "openapi.yaml").write_text("openapi: 3.0\n", encoding="utf-8") + result = _run_powershell_test_dir(tasks_repo, tasks_repo / "contracts") + # Test-DirHasFiles returns a boolean and pwsh still exits 0 when it returns + # $false, so the [OK] marker in stdout β€” not the return code β€” is what proves + # non-emptiness. + assert "[OK]" in result.stdout and "[FAIL]" not in result.stdout, result.stderr + result.stdout diff --git a/tests/test_shared_infra_integrity.py b/tests/test_shared_infra_integrity.py new file mode 100644 index 0000000000..548d2d5f0b --- /dev/null +++ b/tests/test_shared_infra_integrity.py @@ -0,0 +1,101 @@ +"""Unit tests for the shared archive-integrity helper. + +These exercise ``verify_archive_sha256`` directly (independently of the +extension/preset download paths that call it) so the digest-matching, +mismatch, normalisation and "no digest declared" behaviours are pinned in +one place. +""" + +from __future__ import annotations + +import hashlib +import logging + +import pytest + +from specify_cli.shared_infra import verify_archive_sha256 + + +class _BoomError(Exception): + """Sentinel error type used to assert the helper raises ``error_cls``.""" + + +def test_matching_digest_passes(): + """A digest that matches the data returns without raising.""" + data = b"hello-archive" + digest = hashlib.sha256(data).hexdigest() + verify_archive_sha256(data, digest, "thing", _BoomError) + + +def test_mismatch_raises_error_cls(): + """A non-matching digest raises the caller-supplied error type.""" + with pytest.raises(_BoomError, match="[Ii]ntegrity"): + verify_archive_sha256(b"data", "0" * 64, "thing", _BoomError) + + +def test_sha256_prefix_is_accepted(): + """A ``sha256:`` prefix on the expected digest is tolerated.""" + data = b"prefixed" + digest = hashlib.sha256(data).hexdigest() + verify_archive_sha256(data, f"sha256:{digest}", "thing", _BoomError) + + +def test_comparison_is_case_insensitive(): + """An upper-cased expected digest still matches the lower-case actual.""" + data = b"casing" + digest = hashlib.sha256(data).hexdigest().upper() + verify_archive_sha256(data, digest, "thing", _BoomError) + + +def test_malformed_digest_is_rejected(): + """A declared digest that is not 64 hex chars is rejected up front. + + A too-short, too-long, or non-hex value is an authoring/catalog error and + must surface clearly instead of being treated as a digest that simply does + not match the archive. + """ + for bad in ("deadbeef", "z" * 64, "0" * 63, "0" * 65): + with pytest.raises(_BoomError, match="[Ii]nvalid sha256"): + verify_archive_sha256(b"data", bad, "thing", _BoomError) + + +def test_non_sha256_prefix_is_not_silently_stripped(): + """Only a literal ``sha256:`` prefix is stripped. + + A different algorithm prefix (e.g. ``md5:``) must not be silently dropped + and accepted as if the remaining characters were a valid SHA-256 digest; + the value is rejected as malformed. + """ + data = b"prefixed" + digest = hashlib.sha256(data).hexdigest() + with pytest.raises(_BoomError, match="[Ii]nvalid sha256"): + verify_archive_sha256(data, f"md5:{digest}", "thing", _BoomError) + + +def test_absent_digest_skips_and_logs_debug(caplog): + """When no digest is declared the helper returns and logs at DEBUG. + + Installs stay backwards compatible (no error, no user-facing warning), + but the unverified download leaves an audit trail for operators who opt + into debug logging. + """ + with caplog.at_level(logging.DEBUG, logger="specify_cli.shared_infra"): + verify_archive_sha256(b"data", None, "thing", _BoomError) + assert any( + "not verified" in r.getMessage() and "thing" in r.getMessage() + for r in caplog.records + ) + + +def test_blank_declared_digest_is_rejected(): + """A present-but-empty ``sha256`` is an authoring error, not an opt-out. + + Catalog entries reach the helper via ``...get("sha256")``; a blank value + (``""``, whitespace, or a bare ``sha256:`` prefix) means the digest was + declared but left empty. It must surface as a malformed digest rather than + silently disabling the integrity check, which a bare ``if not expected`` + guard would have done. + """ + for blank in ("", " ", "sha256:"): + with pytest.raises(_BoomError, match="[Ii]nvalid sha256"): + verify_archive_sha256(b"data", blank, "thing", _BoomError) diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 1856afb972..2a0a2ca696 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -240,6 +240,17 @@ def test_sequential_default_with_existing_specs(self, git_repo: Path): assert branch is not None assert re.match(r"^\d{3,}-new-feat$", branch), f"unexpected branch: {branch}" + def test_branch_name_short_word_case_sensitivity(self, git_repo: Path): + """A short word is dropped from the derived branch name unless it appears + as an acronym in UPPERCASE in the description. The PowerShell twin must use + case-sensitive -cmatch to produce the same result.""" + r1 = run_script(git_repo, "--json", "--dry-run", "Add go support") + assert r1.returncode == 0, r1.stderr + assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support" + r2 = run_script(git_repo, "--json", "--dry-run", "Use GO now") + assert r2.returncode == 0, r2.stderr + assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now" + def test_sequential_ignores_timestamp_dirs(self, git_repo: Path): """Sequential numbering skips timestamp dirs when computing next number.""" (git_repo / "specs" / "002-first-feat").mkdir(parents=True) @@ -264,6 +275,19 @@ 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}" + def test_explicit_number_zero_is_honored(self, git_repo: Path): + """An explicit --number 0 is honored literally (FEATURE_NUM 000), not treated + as auto-detect, even when higher-numbered specs already exist. This pins the + canonical bash behavior the PowerShell twin must mirror.""" + (git_repo / "specs" / "003-existing").mkdir(parents=True) + r = run_script( + git_repo, "--json", "--dry-run", "--number", "0", "--short-name", "zero", "Zero feature", + ) + assert r.returncode == 0, r.stderr + data = json.loads(r.stdout) + assert data["FEATURE_NUM"] == "000" + assert data["BRANCH_NAME"] == "000-zero" + class TestSequentialBranchPowerShell: def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): @@ -272,6 +296,63 @@ def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): assert "[long]::TryParse($matches[1], [ref]$num)" in content assert "$num = [int]$matches[1]" not in content + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_branch_name_short_word_case_sensitivity(self, ps_git_repo: Path): + """Core create-new-feature.ps1 must drop a short word unless it appears as + an acronym in UPPERCASE (case-sensitive -cmatch), matching the bash twin.""" + script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1" + + def _run(desc: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), "-Json", "-DryRun", desc], + cwd=ps_git_repo, capture_output=True, text=True, + ) + + r1 = _run("Add go support") + assert r1.returncode == 0, r1.stderr + assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support" + r2 = _run("Use GO now") + assert r2.returncode == 0, r2.stderr + assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now" + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_explicit_number_zero_is_honored_matching_bash(self, ps_git_repo: Path): + """An explicit -Number 0 must be honored (FEATURE_NUM 000) like the bash twin, + even when higher-numbered specs exist. Before the fix, PowerShell could not + distinguish -Number 0 from the default and silently auto-detected (e.g. 004).""" + script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1" + (ps_git_repo / "specs" / "003-existing").mkdir(parents=True) + result = subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), + "-Json", "-DryRun", "-Number", "0", "-ShortName", "zero", "Zero feature"], + cwd=ps_git_repo, capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["FEATURE_NUM"] == "000" + assert data["BRANCH_NAME"] == "000-zero" + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_missing_spec_template_warns_matching_bash(self, ps_git_repo: Path): + """When no spec template can be resolved, create-new-feature.ps1 must warn on + stderr (and still create an empty spec file), matching the bash twin's + 'Warning: Spec template not found; created empty spec file'. Before the fix + PowerShell created the empty file silently.""" + # Remove the template the fixture installs so resolution finds nothing. + (ps_git_repo / ".specify" / "templates" / "spec-template.md").unlink() + script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1" + result = subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), + "-Json", "-ShortName", "no-tmpl", "No template feature"], + cwd=ps_git_repo, capture_output=True, text=True, encoding="utf-8", + ) + assert result.returncode == 0, result.stderr + assert "Spec template not found" in result.stderr + # stdout stays parseable JSON and the empty spec file is still created. + data = json.loads(result.stdout) + spec_file = Path(data["SPEC_FILE"]) + assert spec_file.is_file() + # ── check_feature_branch Tests ─────────────────────────────────────────────── @@ -869,6 +950,52 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" +# ── Short-Word / Acronym Branch-Name Tests ────────────────────────────────── + + +def _branch_from_output(stdout: str) -> str | None: + for line in stdout.splitlines(): + if line.startswith("BRANCH_NAME:"): + return line.split(":", 1)[1].strip() + return None + + +SHORT_WORD_CASES = [ + # description, expected branch β€” "go" (lowercase short word) is dropped, + # "AI" (uppercase short word / acronym) is kept, "now" (>=3 chars) is kept. + ("go AI now", "001-ai-now"), + # A short word that is lowercase everywhere is dropped entirely. + ("go to the pub", "001-pub"), +] + + +@requires_bash +class TestShortWordRetentionBash: + """A short word is kept only when it appears in uppercase (an acronym).""" + + @pytest.mark.parametrize("description,expected", SHORT_WORD_CASES) + def test_short_word_retention(self, git_repo: Path, description: str, expected: str): + result = run_script(git_repo, "--dry-run", description) + assert result.returncode == 0, result.stderr + assert _branch_from_output(result.stdout) == expected + + +@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available") +class TestShortWordRetentionPowerShell: + """PowerShell must match bash: a short word is kept only when uppercase. + + Regression guard for the `-match` (case-insensitive) vs `-cmatch` + (case-sensitive) divergence β€” with `-match`, every short non-stop word + leaked into the branch name even when it was lowercase. + """ + + @pytest.mark.parametrize("description,expected", SHORT_WORD_CASES) + def test_short_word_retention(self, ps_git_repo: Path, description: str, expected: str): + result = run_ps_script(ps_git_repo, "-DryRun", description) + assert result.returncode == 0, result.stderr + assert _branch_from_output(result.stdout) == expected + + # ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000000..869c9ff9cc --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,15 @@ +"""Tests for specify_cli._utils.run_command.""" + +from __future__ import annotations + +import inspect + +import pytest + +from specify_cli import run_command + + +def test_run_command_rejects_shell_execution_compatibly(): + assert inspect.signature(run_command).parameters["shell"].default is False + with pytest.raises(ValueError, match="does not support shell=True"): + run_command(["echo", "blocked"], shell=True) # noqa: S604 diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 512b354158..eebc89fadd 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -268,6 +268,81 @@ def test_boolean_or(self): ctx = StepContext(inputs={"a": False, "b": True}) assert evaluate_expression("{{ inputs.a or inputs.b }}", ctx) is True + def test_list_literal_preserves_quoted_commas(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + # commas inside a double-quoted element must not split it + assert evaluate_expression('{{ ["a, b", "c"] }}', ctx) == ["a, b", "c"] + assert evaluate_expression('{{ ["x, y, z"] }}', ctx) == ["x, y, z"] + # single-quoted elements are handled the same way + assert evaluate_expression("{{ ['a, b', 'c'] }}", ctx) == ["a, b", "c"] + assert evaluate_expression("{{ ['p, q, r'] }}", ctx) == ["p, q, r"] + # plain and empty lists still parse correctly + assert evaluate_expression("{{ [1, 2, 3] }}", ctx) == [1, 2, 3] + assert evaluate_expression("{{ [] }}", ctx) == [] + # nested lists (commas inside the inner brackets) stay intact + assert evaluate_expression('{{ [["a", "b"], "c"] }}', ctx) == [["a", "b"], "c"] + assert evaluate_expression("{{ [[1, 2], [3, 4]] }}", ctx) == [[1, 2], [3, 4]] + + def test_operator_splitting_is_quote_aware(self): + from specify_cli.workflows.expressions import ( + evaluate_condition, + evaluate_expression, + ) + from specify_cli.workflows.base import StepContext + + # An 'and'/'or'/'in' keyword INSIDE a quoted operand must not be treated + # as a boolean/membership operator: the comparison applies to the whole + # string literal. + ctx = StepContext(inputs={"mode": "read and write"}) + assert evaluate_expression("{{ inputs.mode == 'read and write' }}", ctx) is True + assert evaluate_expression("{{ inputs.mode == 'read or write' }}", ctx) is False + # ...also when the quoted literal is on the left of the operator. + left_ctx = StepContext(inputs={"x": "approve or reject"}) + assert evaluate_expression("{{ 'approve or reject' == inputs.x }}", left_ctx) is True + # membership against a literal that contains a keyword + assert evaluate_expression("{{ 'cat' in 'cat and dog' }}", StepContext()) is True + + # Literal-vs-literal equality no longer mis-strips to a garbage string + # (previously `'done' == 'failed'` short-circuited to the truthy string + # "done' == 'failed"). + assert evaluate_condition("{{ 'done' == 'failed' }}", StepContext()) is False + assert evaluate_condition("{{ 'done' == 'done' }}", StepContext()) is True + + # A single quoted literal that itself contains operator text is preserved. + assert evaluate_expression("{{ 'a == b' }}", StepContext()) == "a == b" + assert evaluate_expression("{{ 'x and y' }}", StepContext()) == "x and y" + + # Regression: ordinary (unquoted-keyword) parsing still works. + plain = StepContext(inputs={"a": 1, "b": 2, "mode": "read"}) + assert evaluate_expression("{{ inputs.mode == 'read' }}", plain) is True + assert evaluate_expression("{{ inputs.a == 1 and inputs.b == 2 }}", plain) is True + assert evaluate_expression("{{ inputs.a == 9 or inputs.b == 2 }}", plain) is True + assert evaluate_expression("{{ inputs.missing | default('a and b') }}", plain) == "a and b" + + def test_pipe_detection_is_quote_aware(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + # A literal '|' inside a quoted operand must not be treated as a filter + # pipe: the comparison applies to the whole string. + ctx = StepContext(inputs={"x": "a|b"}) + assert evaluate_expression("{{ inputs.x == 'a|b' }}", ctx) is True + assert evaluate_expression("{{ inputs.x == 'a|b' }}", StepContext(inputs={"x": "z"})) is False + # membership against a literal containing a pipe + assert evaluate_expression("{{ 'a|b' in inputs.s }}", StepContext(inputs={"s": "x a|b y"})) is True + # a single quoted literal containing pipes is preserved + assert evaluate_expression("{{ 'a|b|c' }}", StepContext()) == "a|b|c" + + # Regression: real filters still work, including a pipe inside a filter arg. + ctx2 = StepContext(inputs={"items": ["a", "b"], "s": "xabz"}) + assert evaluate_expression("{{ inputs.missing | default('y') }}", ctx2) == "y" + assert evaluate_expression('{{ inputs.items | join("-") }}', ctx2) == "a-b" + assert evaluate_expression("{{ inputs.s | contains('ab') }}", ctx2) is True + assert evaluate_expression("{{ inputs.missing | default('a|b') }}", ctx2) == "a|b" + def test_filter_default(self): from specify_cli.workflows.expressions import evaluate_expression from specify_cli.workflows.base import StepContext @@ -1380,6 +1455,23 @@ def test_validate_invalid_on_reject(self): }) assert any("on_reject" in e for e in errors) + def test_validate_non_string_options_does_not_raise(self): + """Non-string options with on_reject=abort/retry must be REPORTED as an + error, not crash: the reject-choice check calls o.lower() on each option, + which previously raised AttributeError on a non-string option and broke + validate_workflow's 'return errors, never raise' contract.""" + from specify_cli.workflows.steps.gate import GateStep + + step = GateStep() + # on_reject defaults to "abort", which triggers the option-text check. + errors = step.validate({"id": "test", "message": "Review", "options": [123]}) + assert any("must be strings" in e for e in errors) + # also with an explicit retry on_reject + errors = step.validate( + {"id": "test", "message": "Review", "options": [True], "on_reject": "retry"} + ) + assert any("must be strings" in e for e in errors) + def test_interactive_prompt_renders_show_file(self, tmp_path, monkeypatch, capsys): from specify_cli.workflows.steps.gate import GateStep from specify_cli.workflows.base import StepContext, StepStatus @@ -1953,6 +2045,128 @@ def test_validate_wait_for_not_list(self): assert any("non-empty list" in e for e in errors) +class TestFanInWaitForValidation: + """fan-in wait_for must reference a declared step (no silent empty join).""" + + @staticmethod + def _errors(yaml_text): + from specify_cli.workflows.engine import ( + WorkflowDefinition, + validate_workflow, + ) + + return validate_workflow(WorkflowDefinition.from_string(yaml_text)) + + def test_unknown_wait_for_id_is_rejected(self): + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: collect + type: fan-in + wait_for: [ghost] +""") + assert any( + "unknown or not-yet-declared step id 'ghost'" in e for e in errors + ) + + def test_wait_for_declared_earlier_step_passes(self): + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: produce + type: command + command: speckit.implement + - id: collect + type: fan-in + wait_for: [produce] +""") + assert not any("wait_for" in e for e in errors) + + def test_wait_for_conditionally_declared_step_passes(self): + # A step declared inside an if-branch may be skipped at runtime, but it is + # still "declared", so referencing it must validate β€” a legitimately-empty + # runtime join stays valid. + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: maybe + type: if + condition: "{{ inputs.flag }}" + then: + - id: branch_task + type: command + command: speckit.implement + - id: collect + type: fan-in + wait_for: [branch_task] +""") + assert not any("wait_for" in e for e in errors) + + def test_forward_reference_is_rejected(self): + # wait_for points at a step declared AFTER the fan-in; its results cannot + # exist when the fan-in runs, so it is flagged. + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: collect + type: fan-in + wait_for: [later] + - id: later + type: command + command: speckit.implement +""") + assert any( + "unknown or not-yet-declared step id 'later'" in e for e in errors + ) + + def test_self_reference_is_rejected(self): + # A fan-in's own id is in scope by the time it is validated, so a + # self-reference slips past the membership check while still producing + # an empty join at runtime. + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: collect + type: fan-in + wait_for: [collect] +""") + assert any( + "references itself" in e and "collect" in e for e in errors + ) + + def test_non_string_wait_for_entry_is_rejected(self): + # A non-string entry (e.g. YAML `wait_for: [123]`) can never match a + # real step id, so it must be flagged rather than silently ignored. + errors = self._errors(""" +workflow: + id: wf + name: wf + version: "1.0.0" +steps: + - id: collect + type: fan-in + wait_for: [123] +""") + assert any( + "must be step-id strings" in e and "int" in e for e in errors + ) + + # ===== Workflow Definition Tests ===== class TestWorkflowDefinition: @@ -2115,6 +2329,148 @@ def test_invalid_input_type(self): errors = validate_workflow(definition) assert any("invalid type" in e.lower() for e in errors) + def test_requires_with_recognized_keys_is_valid(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: + speckit_version: ">=0.7.2" + integrations: + any: ["claude", "gemini"] +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert errors == [] + + def test_requires_must_be_mapping(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: "claude" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("'requires' must be a mapping" in e for e in errors) + + def test_requires_unknown_key_is_rejected(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: + speckit_version: ">=0.7.2" + typo_key: true +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("typo_key" in e and "requires" in e for e in errors) + + def test_requires_permissions_is_rejected_as_not_enforced(self): + """A `requires.permissions` block looks like a runtime capability gate + but no such gate exists β€” shell steps always run with the user's + privileges. Reject it explicitly so authors are not misled into + believing the declaration sandboxes execution. + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: + permissions: + shell: true +steps: + - id: run + type: shell + run: "echo hi" +""") + errors = validate_workflow(definition) + # Assert on specific markers from the intended message (the offending + # key and the `gate` remediation) so the test fails if the validation + # path or wording drifts, rather than passing on any error that merely + # happens to contain "permissions" and "not". + assert any("requires.permissions" in e and "gate" in e for e in errors) + + def test_requires_empty_sequence_is_rejected_as_non_mapping(self): + """A non-mapping ``requires`` (e.g. an empty list) is an authoring + error. Mirroring ``inputs``, validation checks ``isinstance(..., dict)`` + so ``requires: []`` surfaces instead of silently passing. + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: [] +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("'requires' must be a mapping" in e for e in errors) + + def test_requires_yaml_null_is_rejected_as_non_mapping(self): + """A bare ``requires:`` parses as YAML null. Like ``inputs``, a present + block must be a mapping, so YAML null is rejected as an authoring error + rather than being silently treated as an omitted block. (A truly + omitted ``requires`` defaults to ``{}`` and stays valid.) + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +requires: +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("'requires' must be a mapping" in e for e in errors) + + def test_requires_omitted_is_valid(self): + """A workflow with no ``requires`` block at all defaults to ``{}`` and + must validate cleanly β€” only a present-but-non-mapping value is an + error (guards against over-correcting YAML-null rejection into also + flagging the omitted case). + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert not any("requires" in e for e in errors) + # ===== Workflow Engine Tests ===== @@ -2650,6 +3006,47 @@ def test_validate_workflow_rejects_bool_default_for_number_type(self): errors = validate_workflow(definition) assert any("invalid default" in e for e in errors), errors + def test_coerce_number_input_rejects_infinity_cleanly(self): + """An infinite float must surface as a clean ValueError (like NaN), not + let ``int(inf)``'s OverflowError escape: ``int()`` of an infinity raises + OverflowError, which is not ValueError/TypeError. + """ + from specify_cli.workflows.engine import WorkflowEngine + + for value in (float("inf"), float("-inf"), "inf", "Infinity", "-inf"): + with pytest.raises(ValueError, match="expected a number"): + WorkflowEngine._coerce_input("count", value, {"type": "number"}) + # Finite values still coerce (whole floats normalize to int). + assert WorkflowEngine._coerce_input("count", 5.0, {"type": "number"}) == 5 + assert WorkflowEngine._coerce_input("count", 3.5, {"type": "number"}) == 3.5 + + def test_validate_workflow_rejects_infinite_default_for_number_type(self): + """``type: number`` with an infinite default (YAML ``.inf``) must be + reported as an error, not raise. ``int(inf)`` raises OverflowError during + coercion, which previously escaped validate_workflow's ValueError handler + and broke its "return a list of errors" contract. + """ + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +schema_version: "1.0" +workflow: + id: "inf-as-number" + name: "Inf As Number" + version: "1.0.0" +inputs: + count: + type: number + default: .inf +steps: + - id: noop + type: gate + message: "noop" + options: [approve] +""") + errors = validate_workflow(definition) + assert any("invalid default" in e for e in errors), errors + def test_validate_workflow_rejects_non_string_default_for_string_type(self): """``type: string`` must require an actual string β€” a numeric YAML default like ``5`` would otherwise slip through unvalidated. @@ -5317,6 +5714,137 @@ def fake_open_url(url, timeout=None, extra_headers=None): assert len(asset_calls) >= 1 assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + def test_workflow_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): + """'workflow add ' resolves via GHES /api/v3 endpoint.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + ]) + + captured_urls = [] + + class FakeResponse: + def __init__(self, data, url=None): + self._data = data + self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/42" + + def read(self): + return self._data + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fake_open_url(url, timeout=None, extra_headers=None): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return FakeResponse(json.dumps({ + "assets": [{"name": "workflow.yml", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}] + }).encode()) + return FakeResponse(self.VALID_WORKFLOW_YAML.encode()) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url): + result = runner.invoke(app, [ + "workflow", "add", + "https://ghes.example/org/repo/releases/download/v1.0/workflow.yml", + ]) + + assert result.exit_code == 0, result.output + # Tag lookup must use the GHES /api/v3 endpoint + assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls) + # Asset download must carry Accept: application/octet-stream + asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + + def test_workflow_add_catalog_based_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch): + """'workflow add ' with a GHES catalog URL resolves via /api/v3.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + from specify_cli.authentication import http as _auth_http + from specify_cli.authentication.config import AuthConfigEntry + + monkeypatch.setattr(_auth_http, "_config_override", [ + AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"), + ]) + + captured_urls = [] + + class FakeResponse: + def __init__(self, data, url=None): + self._data = data + self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/55" + + def read(self): + return self._data + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + ghes_wf_yaml = """ +schema_version: "1.0" +workflow: + id: "my-wf" + name: "My GHES Workflow" + version: "1.0.0" + description: "A GHES catalog workflow" +steps: + - id: step-one + type: shell + run: "echo hello" +""" + + def fake_open_url(url, timeout=None, extra_headers=None): + captured_urls.append((url, extra_headers)) + if "releases/tags/" in url: + return FakeResponse(json.dumps({ + "assets": [{"name": "workflow.yml", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/55"}] + }).encode()) + return FakeResponse(ghes_wf_yaml.encode()) + + fake_catalog_info = { + "id": "my-wf", + "name": "My GHES Workflow", + "version": "1.0.0", + "url": "https://ghes.example/org/repo/releases/download/v2.0/workflow.yml", + "_install_allowed": True, + } + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \ + patch("specify_cli.workflows.catalog.WorkflowCatalog.get_workflow_info", return_value=fake_catalog_info): + result = runner.invoke(app, ["workflow", "add", "my-wf"]) + + assert result.exit_code == 0, result.output + # Tag lookup must use GHES /api/v3 + tag_calls = [url for url, _ in captured_urls if "releases/tags/" in url] + assert len(tag_calls) == 1 + assert "ghes.example/api/v3/repos/org/repo/releases/tags/v2.0" in tag_calls[0] + # Asset download must carry Accept: application/octet-stream + asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url] + assert len(asset_calls) >= 1 + assert asset_calls[0][1] == {"Accept": "application/octet-stream"} + class TestWorkflowRunExitCodes: """CLI-level tests for the run/resume process exit codes.""" diff --git a/workflows/PUBLISHING.md b/workflows/PUBLISHING.md index ce0d251826..d250545dc6 100644 --- a/workflows/PUBLISHING.md +++ b/workflows/PUBLISHING.md @@ -268,10 +268,22 @@ When releasing a new version: ### Shell Steps +- **Shell runs with the user's privileges** β€” a `shell` step executes a local command directly; there is no capability sandbox. `requires` is an advisory pre-condition block (recognised keys: `speckit_version`, `integrations`), **not** a runtime permission gate β€” there is no `requires.permissions`. Gate sensitive commands explicitly with a `gate` step. - **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 +#### Security: shell steps execute arbitrary code + +Workflow `shell` steps execute their `run` field through `/bin/sh` (POSIX) or the platform shell. There is no sandbox between the step and the user's machine: a malicious or buggy `run` block can read environment variables, modify files outside the project, exfiltrate data, or escalate privileges. + +Catalog-listed workflows are reviewed at submission time (see [Verification Process](#verification-process)), but you should still treat every install as code-execution from an untrusted source until you have read the `workflow.yml`: + +- **Before installing a workflow**, fetch the raw YAML and audit every `shell` step's `run` field directly. `specify workflow info ` only shows metadata (name, version, inputs, step IDs/types) β€” not the shell content that would actually execute. +- **Prefer explicit commands over interpolation** in `run` blocks: `{{ inputs.something }}` substitutions should be quoted and constrained via `enum` so a malicious input can't inject shell syntax. +- **Limit privilege**: shell steps inherit the user's environment. Workflows that need elevated access (sudo, secrets, GitHub tokens) should call them out explicitly in the README so reviewers can spot the requirement. +- **Authors**: if your workflow has shell steps that look risky out of context (deletions, network calls, credential reads), document the rationale in your README. Maintainers will reject submissions whose shell steps can't be justified at review time. + ### Integration Flexibility - **Set `integration` at workflow level** β€” use the `workflow.integration` field as the default