Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
77bf078c9d chore: bump version to 0.11.7 2026-06-24 20:00:03 +00:00
75 changed files with 293 additions and 3115 deletions

View File

@@ -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 @earendil-works/pi-coding-agent@latest"
run_command "npm install -g @mariozechner/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 Code CLI..."
echo -e "\n🤖 Installing Kimi CLI..."
# https://code.kimi.com
run_command "npm install -g @moonshot-ai/kimi-code@latest"
run_command "pipx install kimi-cli"
echo "✅ Done"
echo -e "\n🤖 Installing CodeBuddy CLI..."

View File

@@ -1,293 +0,0 @@
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 <catalog-url> --id <catalog-id> --policy install-allowed` and `specify bundle install <bundle-id>`
- 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 <bundle-directory>`
required: true
- label: Build succeeds with `specify bundle build --path <bundle-directory>` 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.

View File

@@ -77,18 +77,6 @@ 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/<id>/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 <download-url>` using the exact Download URL above (other forms such as `specify preset add <preset-id>` or `specify preset add --dev <path>` 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:
@@ -187,7 +175,7 @@ body:
options:
- label: Valid `preset.yml` manifest included
required: true
- label: Linked README (Documentation URL) explains how to use this preset and includes a valid `specify preset add ...` command (preferably `specify preset add --from <download-url>` using the exact download URL)
- label: README.md with description and usage instructions
required: true
- label: LICENSE file included
required: true

View File

@@ -1,4 +1,4 @@
# 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-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-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
#

View File

@@ -73,7 +73,6 @@ 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 |
@@ -101,70 +100,17 @@ 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
> 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/<owner>/<repo>/blob/<ref>/<path>`
- `https://github.com/<owner>/<repo>/raw/<ref>/<path>`
- `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`
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/<owner>/<repo>/blob/<ref>/<path>` URL, fetch the equivalent
`github.com/<owner>/<repo>/raw/<ref>/<path>` URL (only swap `/blob/``/raw/`).
- Fetch `github.com/.../raw/...` and `raw.githubusercontent.com/...` URLs as-is.
Do **not** rewrite into `raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>` 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 <download-url>` (preferred), or
- `specify preset add <preset-id>`, or
- `specify preset add --dev <path>`
A `specify preset add --from <url>` command only counts when its `<url>` **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 <preset-id>` or
`specify preset add --dev <path>`).
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 <download-url>`).
- **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/<id>/` 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/<id>/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
### 2d. Release and download URL validation
- The download URL should follow the pattern
`https://github.com/<owner>/<repo>/archive/refs/tags/v<version>.zip`
or
`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`
- Verify a GitHub release exists matching the submitted version
### 2f. Submission checklists
### 2e. Submission checklists
- Confirm that all required checkboxes in the Testing Checklist and Submission
Requirements sections are checked (`[x]`)
@@ -208,7 +154,7 @@ Insert the entry in **alphabetical order by preset ID** within the
"repository": "<repository>",
"download_url": "<download_url>",
"homepage": "<homepage or repository>",
"documentation": "<documentation URL — the validated preset-usage README>",
"documentation": "<documentation or repository README>",
"license": "<license>",
"requires": {
"speckit_version": "<speckit_version>"

View File

@@ -19,7 +19,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
- uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;

View File

@@ -42,15 +42,3 @@ 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

View File

@@ -35,7 +35,7 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.13"

View File

@@ -19,7 +19,7 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ matrix.python-version }}

View File

@@ -2,55 +2,6 @@
<!-- insert new changelog below this comment -->
## [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

View File

@@ -113,16 +113,6 @@ 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

View File

@@ -134,14 +134,13 @@ 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), the [Presets Publishing Guide](presets/PUBLISHING.md), or the [Community Bundles guide](docs/community/bundles.md).
Want to contribute? See the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md) or the [Presets Publishing Guide](presets/PUBLISHING.md).
## 🤖 Supported AI Coding Agent Integrations
@@ -263,10 +262,8 @@ 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. 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:
Authors validate and package bundles locally — there is no first-class publish;
distribution is hosting the built artifact and adding a catalog entry:
```bash
specify bundle validate --path ./my-bundle # structural + reference checks

View File

@@ -1,53 +0,0 @@
# 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.

View File

@@ -56,7 +56,6 @@ 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) |
@@ -118,7 +117,6 @@ 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) |

View File

@@ -7,9 +7,7 @@ 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.
- **[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`.
- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH.
- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates.

View File

@@ -1,6 +1,6 @@
# Community
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.
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.
## Extensions
@@ -14,12 +14,6 @@ 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.

View File

@@ -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 | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
| 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) |
| 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) |

View File

@@ -26,7 +26,6 @@ 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
@@ -51,7 +50,6 @@ 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

View File

@@ -94,15 +94,8 @@ 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:

View File

@@ -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.plan -> /speckit.checklist -> /speckit.tasks -> /speckit.analyze -> /speckit.implement -> /speckit.converge
/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.checklist -> /speckit.plan -> /speckit.tasks -> /speckit.analyze -> /speckit.implement
```
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.
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.
### Step 1: Install Specify
@@ -75,6 +75,12 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
/speckit.clarify Focus on security and performance requirements.
```
Then validate the requirements with `/speckit.checklist` before creating the technical plan:
```bash
/speckit.checklist
```
### Step 5: Create a Technical Implementation Plan
**In the chat**, use the `/speckit.plan` slash command to provide your tech stack and architecture choices.
@@ -83,12 +89,6 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
```
Then generate quality checklists with `/speckit.checklist` once the plan exists:
```bash
/speckit.checklist
```
### Step 6: Break Down, Analyze, and Implement
**In the chat**, use the `/speckit.tasks` slash command to create an actionable task list.
@@ -150,7 +150,15 @@ You can continue to refine the spec with more details using `/speckit.clarify`:
/speckit.clarify When you first launch Taskify, it's going to give you a list of the five users to pick from. There will be no password required. When you click on a user, you go into the main view, which displays the list of projects. When you click on a project, you open the Kanban board for that project. You're going to see the columns. You'll be able to drag and drop cards back and forth between different columns. You will see any cards that are assigned to you, the currently logged in user, in a different color from all the other ones, so you can quickly see yours. You can edit any comments that you make, but you can't edit comments that other people made. You can delete any comments that you made, but you can't delete comments anybody else made.
```
### Step 4: Generate Technical Plan with `/speckit.plan`
### Step 4: Validate the Spec
Validate the specification checklist using the `/speckit.checklist` command:
```bash
/speckit.checklist
```
### Step 5: Generate Technical Plan with `/speckit.plan`
Be specific about your tech stack and technical requirements:
@@ -158,14 +166,6 @@ Be specific about your tech stack and technical requirements:
/speckit.plan We are going to generate this using .NET Aspire, using Postgres as the database. The frontend should use Blazor server with drag-and-drop task boards, real-time updates. There should be a REST API created with a projects API, tasks API, and a notifications API.
```
### Step 5: Validate the Spec
Generate quality checklists to validate the specification using the `/speckit.checklist` command:
```bash
/speckit.checklist
```
### Step 6: Define Tasks
Generate an actionable task list using the `/speckit.tasks` command:
@@ -188,14 +188,6 @@ Finally, implement the solution:
/speckit.implement
```
### Step 8: Converge
Run the `/speckit.converge` command after implementation to assess the current codebase against the feature's artifacts and append any remaining unbuilt work as new tasks to `tasks.md`. If the command appends new tasks, run `/speckit.implement` again to complete them, and repeat the converge step until the feature is fully complete.
```bash
/speckit.converge
```
> [!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.

View File

@@ -69,33 +69,6 @@ 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 |

View File

@@ -119,12 +119,6 @@ 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 <artifact.zip>`.
## 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).

View File

@@ -26,7 +26,6 @@ specify extension add <name>
| --------------- | -------------------------------------------------------- |
| `--dev` | Install from a local directory (for development) |
| `--from <url>` | Install from a custom URL instead of the catalog |
| `--force` | Overwrite if already installed |
| `--priority <N>`| 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.

View File

@@ -25,7 +25,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [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; 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` |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | 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` | |
@@ -100,7 +100,6 @@ specify integration switch <key>
| ------------------------ | ------------------------------------------------------------------------ |
| `--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 <key> --integration-options ...` first, then `use <key>`.
@@ -159,7 +158,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 `.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` |
| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format |
Example:
@@ -185,7 +184,6 @@ 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` |
@@ -194,6 +192,7 @@ 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` |
@@ -201,7 +200,6 @@ 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`.

View File

@@ -137,11 +137,9 @@ catalogs:
## File Resolution
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.
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.
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.
> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release.
The resolution stack, from highest to lowest precedence:
@@ -150,6 +148,8 @@ 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 <name>` 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 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`.
**Disabling** (`specify preset disable`) keeps the preset installed but excludes its files from the resolution stack. Commands the preset registered remain available in your AI coding agent. This is useful for temporarily testing behavior without a preset, or comparing output with and without it. Re-enable anytime with `specify preset enable`.
**Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry.

View File

@@ -66,8 +66,6 @@
href: community/extensions.md
- name: Presets
href: community/presets.md
- name: Bundles
href: community/bundles.md
- name: Walkthroughs
href: community/walkthroughs.md
- name: Friends

View File

@@ -10,9 +10,9 @@
#
# Usage: update-agent-context.sh [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.
# 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.
set -euo pipefail
@@ -202,78 +202,23 @@ unset _cf_parts _seg
PLAN_PATH="${1:-}"
if [[ -z "$PLAN_PATH" ]]; then
# 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
# Pick the most recently modified plan.md one level deep (specs/<feature>/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
from pathlib import Path
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"
specs = Path(sys.argv[1]) / "specs"
plans = sorted(
specs.glob("*/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if plans:
try:
print(plans[0].relative_to(root).as_posix())
except ValueError:
print("")
else:
print("")
print(plans[0] if plans else "")
PY
)"
if [[ -n "$_plan_rel" ]]; then
PLAN_PATH="$_plan_rel"
fi
if [[ -n "$_plan_abs" ]]; then
PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
fi
fi

View File

@@ -9,10 +9,6 @@
# .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(
@@ -130,26 +126,14 @@ if (-not (Test-Path -LiteralPath $ExtConfig)) {
$Options = $null
if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
try {
$Options = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 | ConvertFrom-Yaml -ErrorAction Stop
$Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop
} catch {
# fall through to ConvertFrom-Json fallback
# fall through to Python fallback
}
}
if ($null -eq $Options) {
# 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.
# ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
$pythonCmd = $null
$pythonCandidates = @()
if ($env:SPECKIT_PYTHON) {
@@ -296,69 +280,21 @@ if ($cm) {
}
if (-not $PlanPath) {
# 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.
# Discover plan.md exactly one level deep (specs/<feature>/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('\','/')
}
} catch {
# Non-fatal: continue without a plan path.
}
}

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-24T00:00:00Z",
"updated_at": "2026-06-23T00: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 composable commands you can customize with hooks and recipes.",
"description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and a turbo pipeline profile.",
"author": "alfredoperez",
"version": "0.11.0",
"download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.11.0/companion-0.11.0.zip",
"version": "0.3.0",
"download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.3.0/companion-0.3.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/README.md",
"documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/docs/",
"changelog": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/CHANGELOG.md",
"license": "MIT",
"category": "visibility",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.9.5",
"speckit_version": ">=0.8.5",
"tools": [
{ "name": "python3", "required": false }
]
},
"provides": {
"commands": 13,
"commands": 10,
"hooks": 4
},
"tags": [
"vscode",
"tracking",
"companion",
"progress",
"status",
"resume",
"configurable",
"extensible"
"vscode",
"lifecycle",
"resume"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-11T00:00:00Z",
"updated_at": "2026-06-24T00:00:00Z"
"updated_at": "2026-06-11T00:00:00Z"
},
"conduct": {
"name": "Conduct Extension",
@@ -1327,39 +1327,6 @@
"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",
@@ -1581,34 +1548,25 @@
"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.4.0",
"download_url": "https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/tags/v0.4.0.zip",
"version": "0.2.0",
"download_url": "https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/tags/v0.2.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/blob/main/CHANGELOG.md",
"changelog": "https://github.com/ashbrener/spec-kit-jira-sync/releases",
"license": "MIT",
"category": "integration",
"effect": "read-write",
"requires": {
"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 }
]
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 4,
"commands": 2,
"hooks": 0
},
"tags": [
"issue-tracking",
"jira",
"tasks-sync",
"lifecycle-mirror",
"reconcile",
"drift-aware"
],
@@ -1616,7 +1574,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-06-08T00:00:00Z",
"updated_at": "2026-06-24T00:00:00Z"
"updated_at": "2026-06-08T00:00:00Z"
},
"learn": {
"name": "Learning Extension",
@@ -3004,40 +2962,6 @@
"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",

View File

@@ -252,10 +252,7 @@ function Get-BranchName {
if ($stopWords -contains $word) { continue }
if ($word.Length -ge 3) {
$meaningfulWords += $word
} 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.
} elseif ($Description -match "\b$($word.ToUpper())\b") {
$meaningfulWords += $word
}
}

View File

@@ -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**: 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))
3. **Documentation**: README.md with description and usage instructions
4. **License**: Open source license file (MIT, Apache 2.0, etc.)
5. **Versioning**: Semantic versioning (e.g., 1.0.0)
6. **Testing**: Preset tested on real projects with `specify preset add --dev`
@@ -147,46 +147,6 @@ 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/<id>/`), link the README inside that directory
(`presets/<id>/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
# <download-url> 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/<org>/<repo>/archive/refs/tags/vX.Y.Z.zip
specify preset add --from https://github.com/<org>/<repo>/releases/download/vX.Y.Z/<id>-X.Y.Z.zip
```
`specify preset add <id>` and `specify preset add --dev <path>` are also accepted, but the
`--from <download-url>` 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
@@ -221,14 +181,12 @@ 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"
@@ -285,7 +243,7 @@ git push origin add-your-preset
### Checklist
- [ ] Valid preset.yml manifest
- [ ] Usage README with a valid `specify preset add ...` command, linked from `documentation` (preset-scoped README recommended for monorepos)
- [ ] README.md with description and usage
- [ ] LICENSE file included
- [ ] GitHub release created
- [ ] Preset tested with `specify preset add --dev`
@@ -306,15 +264,7 @@ 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** — 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 <url>` form, its URL must match the submitted download URL exactly — other accepted
> forms like `specify preset add <id>` 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.
5. **Documentation**clear README explaining what the preset does
Once verified, `verified: true` is set and the preset appears in `specify preset search`.

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-25T00:00:00Z",
"updated_at": "2026-06-22T00: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.5.1",
"description": "Baseline secure-by-default Spec Kit governance profile.",
"version": "0.4.0",
"description": "Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions.",
"author": "SicarioSpec Contributors",
"repository": "https://github.com/dfirs1car1o/sicario-spec",
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-core-0.5.1.zip",
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.4.0/sicario-core-0.4.0.zip",
"homepage": "https://github.com/dfirs1car1o/sicario-spec",
"documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/presets/sicario-core/README.md",
"documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.9.0"
@@ -583,13 +583,14 @@
"commands": 0
},
"tags": [
"security",
"governance",
"security-ops",
"secure-by-default",
"evidence"
],
"created_at": "2026-06-22T00:00:00Z",
"updated_at": "2026-06-25T00:00:00Z"
"updated_at": "2026-06-22T00:00:00Z"
},
"spec2cloud": {
"name": "Spec2Cloud",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.11.10"
version = "0.11.7"
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"

View File

@@ -142,10 +142,8 @@ if ($ShortName) {
$branchSuffix = Get-BranchName -Description $featureDesc
}
# 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')) {
# Warn if -Number and -Timestamp are both specified
if ($Timestamp -and $Number -ne 0) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
}
@@ -155,10 +153,8 @@ if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
} else {
# 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')) {
# Determine branch number from existing feature directories
if ($Number -eq 0) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}

View File

@@ -40,13 +40,6 @@ 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"
# Create a basic plan file if template doesn't exist

View File

@@ -1128,10 +1128,9 @@ def workflow_add(
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, github_hosts=github_provider_hosts())
_resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30)
if _resolved_wf_url:
source = _resolved_wf_url
_wf_url_extra_headers = {"Accept": "application/octet-stream"}
@@ -1235,11 +1234,10 @@ def workflow_add(
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, github_hosts=github_provider_hosts())
_resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30)
if _resolved_workflow_url:
workflow_url = _resolved_workflow_url
_wf_cat_extra_headers = {"Accept": "application/octet-stream"}

View File

@@ -10,7 +10,6 @@ through the config-driven helpers in :mod:`specify_cli.authentication.http`.
import os
import urllib.request
from fnmatch import fnmatch
from typing import Callable, Dict, Optional
from urllib.parse import quote, unquote, urlparse
@@ -57,79 +56,55 @@ 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 release browser-download URL to its REST API asset URL.
"""Resolve a GitHub browser release 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 private or SSO-protected repositories, browser release download
URLs (``https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>``)
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/<id>``), which can
then be downloaded with ``Accept: application/octet-stream`` and an
auth token to retrieve the actual file payload.
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.
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.
Args:
download_url: The URL to resolve.
open_url_fn: A callable compatible with
``specify_cli.authentication.http.open_url`` used for the
authenticated release-metadata lookup.
``specify_cli.authentication.http.open_url`` used to make the
authenticated API request.
timeout: Per-request timeout in seconds.
github_hosts: Host patterns to treat as GitHub Enterprise Server.
Returns:
The resolved REST API asset URL, or ``None`` if resolution is not
applicable or fails.
"""
import json
import urllib.error
parsed = urlparse(download_url)
hostname = (parsed.hostname or "").lower()
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
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:]):
# 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"]
):
return download_url
# 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:
# Only handle github.com browser release download URLs
if parsed.hostname != "github.com":
return None
# Expecting /<owner>/<repo>/releases/download/<tag>/<asset>
@@ -139,7 +114,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"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
try:
with open_url_fn(release_url, timeout=timeout) as response:

View File

@@ -118,20 +118,6 @@ 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,

View File

@@ -78,10 +78,7 @@ class CatalogStackBase:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
# 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:
if not parsed.netloc:
raise cls._error("Catalog URL must be a valid URL with a host.")
def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None:

View File

@@ -2057,18 +2057,12 @@ class ExtensionCatalog(CatalogStackBase):
) -> Optional[str]:
"""Resolve a GitHub release asset URL to its API asset URL.
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``.
Delegates to the shared helper in :mod:`specify_cli._github_http`.
"""
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,
github_hosts=github_provider_hosts(),
download_url, self._open_url, timeout=timeout
)
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:

View File

@@ -482,7 +482,6 @@ def extension_add(
elif from_url:
# Install from URL (ZIP file)
import io
import urllib.error
console.print(f"Downloading from {safe_url}...")
@@ -499,33 +498,10 @@ def extension_add(
zip_path = Path(download_file.name)
try:
# 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"}
from specify_cli.authentication.http import open_url as _open_url
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
with _open_url(from_url, timeout=60) 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

View File

@@ -22,17 +22,13 @@ ARGUMENT_HINTS: dict[str, str] = {
}
# Per-command frontmatter overrides for skills that should run in a forked
# 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]] = {}
# 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"},
}
class ClaudeIntegration(SkillsIntegration):

View File

@@ -9,7 +9,7 @@ class CodebuddyIntegration(MarkdownIntegration):
"name": "CodeBuddy",
"folder": ".codebuddy/",
"commands_subdir": "commands",
"install_url": "https://www.codebuddy.cn/docs/cli/installation",
"install_url": "https://www.codebuddy.ai/cli",
"requires_cli": True,
}
registrar_config = {

View File

@@ -1,13 +1,11 @@
"""Kimi Code integration — skills-based agent (Moonshot AI).
Kimi uses the ``.kimi-code/skills/speckit-<name>/SKILL.md`` layout with
Kimi uses the ``.kimi/skills/speckit-<name>/SKILL.md`` layout with
``/skill:speckit-<name>`` invocation syntax.
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/`` (including the ``KIMI.md`` → ``AGENTS.md``
context file), and the dotted-to-hyphenated skill naming
(``speckit.xxx`` → ``speckit-xxx``).
Includes legacy migration logic for projects initialised before Kimi
moved from dotted skill directories (``speckit.xxx``) to hyphenated
(``speckit-xxx``).
"""
from __future__ import annotations
@@ -16,7 +14,7 @@ import shutil
from pathlib import Path
from typing import Any
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
from ..base import IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
@@ -26,43 +24,19 @@ class KimiIntegration(SkillsIntegration):
key = "kimi"
config = {
"name": "Kimi Code",
"folder": ".kimi-code/",
"folder": ".kimi/",
"commands_subdir": "skills",
"install_url": "https://code.kimi.com/",
"requires_cli": True,
}
registrar_config = {
"dir": ".kimi-code/skills",
"dir": ".kimi/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
multi_install_safe = False
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build Kimi's native skill invocation: ``/skill:speckit-<stem>``.
Kimi Code CLI invokes installed skills with a ``/skill:<name>``
slash command (e.g. ``/skill:speckit-plan``), not the bare
``/speckit-<name>`` 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-")
context_file = "KIMI.md"
multi_install_safe = True
@classmethod
def options(cls) -> list[IntegrationOption]:
@@ -77,12 +51,7 @@ class KimiIntegration(SkillsIntegration):
"--migrate-legacy",
is_flag=True,
default=False,
help=(
"Migrate legacy Kimi installations: "
".kimi/skills/ → .kimi-code/skills/, speckit.xxx → speckit-xxx, "
"and (when the agent-context extension is enabled) "
"KIMI.md user content → AGENTS.md"
),
help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)",
),
]
@@ -93,397 +62,64 @@ class KimiIntegration(SkillsIntegration):
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install skills with optional legacy migration."""
"""Install skills with optional legacy dotted-name migration."""
parsed_options = parsed_options or {}
# 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.
# Run base setup first so hyphenated targets (speckit-*) exist,
# then migrate/clean legacy dotted dirs without risking user content loss.
created = super().setup(
project_root, manifest, parsed_options=parsed_options, **opts
)
if parsed_options.get("migrate_legacy", False):
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)
# Mirror upsert/remove_context_section: a disabled agent-context
# extension is a full opt-out, so skip the KIMI.md → AGENTS.md
# migration entirely and leave both files untouched.
if self._agent_context_extension_enabled(project_root):
marker_start, marker_end = self._resolve_context_markers(project_root)
_migrate_legacy_kimi_context_file(
project_root, marker_start=marker_start, marker_end=marker_end
)
skills_dir = self.skills_dest(project_root)
if skills_dir.is_dir():
_migrate_legacy_kimi_dotted_skills(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 _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.
def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
"""Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format.
Returns ``(migrated_count, removed_count)``.
"""
if not old_skills_dir.is_dir():
if not skills_dir.is_dir():
return (0, 0)
migrated_count = 0
removed_count = 0
# 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():
for legacy_dir in sorted(skills_dir.glob("speckit.*")):
if not legacy_dir.is_dir():
continue
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():
if not (legacy_dir / "SKILL.md").exists():
continue
target_name = _legacy_to_target_name(legacy_dir.name)
if not target_name:
suffix = legacy_dir.name[len("speckit."):]
if not suffix:
continue
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
target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}"
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.
# 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 exists — only remove legacy if SKILL.md is identical
target_skill = target_dir / "SKILL.md"
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
legacy_skill = legacy_dir / "SKILL.md"
if target_skill.is_file():
try:
if target_skill.read_bytes() == legacy_skill.read_bytes():
has_extra = any(
child.name != "SKILL.md" for child in legacy_dir.iterdir()
)
if not has_extra:
shutil.rmtree(legacy_dir)
removed_count += 1
except OSError:
pass
return (migrated_count, removed_count)
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_context_file(
project_root: Path,
*,
marker_start: str = IntegrationBase.CONTEXT_MARKER_START,
marker_end: str = IntegrationBase.CONTEXT_MARKER_END,
) -> bool:
"""Migrate user content from legacy ``KIMI.md`` to ``AGENTS.md``.
The Speckit managed section is stripped from ``KIMI.md`` before the
remaining content is appended to ``AGENTS.md``. The legacy file is
deleted if it becomes empty. Returns ``True`` if ``KIMI.md`` was
migrated, ``False`` when the migration is skipped.
The migration is skipped (leaving ``KIMI.md`` untouched) in any of these
cases, so a best-effort legacy cleanup never aborts ``setup()`` or
corrupts ``AGENTS.md``:
- ``KIMI.md`` is a symlink, missing, or unreadable (its target could be
read from outside the project, or it may not be valid UTF-8).
- ``AGENTS.md`` is a symlink (it could redirect the write to a file
outside the project root), exists as a non-file (e.g. a directory),
or is unreadable/unwritable.
- ``KIMI.md`` has a corrupted managed section — only one marker is
present, or the end marker precedes the start. Stripping is only done
when both markers are present and well-ordered, so a partial managed
block is never copied into ``AGENTS.md``; the user repairs it manually.
"""
legacy_path = project_root / "KIMI.md"
if legacy_path.is_symlink() or not legacy_path.is_file():
return False
target_path = project_root / "AGENTS.md"
# Never follow a symlinked target, and never treat an existing non-file
# (e.g. a directory) as a writable context file.
if target_path.is_symlink() or (target_path.exists() and not target_path.is_file()):
return False
try:
content = legacy_path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeDecodeError):
return False
marker_pairs = [(marker_start, marker_end)]
default_pair = (
IntegrationBase.CONTEXT_MARKER_START,
IntegrationBase.CONTEXT_MARKER_END,
)
if default_pair not in marker_pairs:
marker_pairs.append(default_pair)
start_idx = -1
end_idx = -1
has_start = False
has_end = False
for s, e in marker_pairs:
s_idx = content.find(s)
e_idx = content.find(e, s_idx if s_idx != -1 else 0)
has_s = s_idx != -1
has_e = e_idx != -1
if not has_s and not has_e:
continue
# Refuse to migrate a corrupted managed section: exactly one marker, or
# an end marker that does not follow the start.
if has_s != has_e or e_idx <= s_idx:
return False
marker_start, marker_end = s, e
start_idx, end_idx = s_idx, e_idx
has_start = True
has_end = True
break
if has_start and has_end:
removal_start = start_idx
removal_end = end_idx + len(marker_end)
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
if removal_start > 0 and content[removal_start - 1] == "\n":
if removal_start > 1 and content[removal_start - 2] == "\n":
removal_start -= 1
content = content[:removal_start] + content[removal_end:]
user_content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
if not user_content:
legacy_path.unlink()
return True
try:
if target_path.is_file():
existing = target_path.read_text(encoding="utf-8-sig")
existing = existing.replace("\r\n", "\n").replace("\r", "\n")
if not existing.endswith("\n"):
existing += "\n"
new_content = existing + "\n" + user_content + "\n"
else:
new_content = user_content + "\n"
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(new_content.encode("utf-8"))
except (OSError, UnicodeDecodeError):
return False
legacy_path.unlink()
return True
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)

View File

@@ -9,7 +9,7 @@ class PiIntegration(MarkdownIntegration):
"name": "Pi Coding Agent",
"folder": ".pi/",
"commands_subdir": "prompts",
"install_url": "https://www.npmjs.com/package/@earendil-works/pi-coding-agent",
"install_url": "https://www.npmjs.com/package/@mariozechner/pi-coding-agent",
"requires_cli": True,
}
registrar_config = {

View File

@@ -1861,10 +1861,7 @@ class PresetCatalog:
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
# 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:
if not parsed.netloc:
raise PresetValidationError(
"Catalog URL must be a valid URL with a host."
)
@@ -1895,19 +1892,10 @@ class PresetCatalog:
download_url: str,
timeout: int = 60,
) -> Optional[str]:
"""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``.
"""
"""Resolve a GitHub release asset URL to its REST API asset URL."""
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,
github_hosts=github_provider_hosts(),
download_url, self._open_url, timeout=timeout
)
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:

View File

@@ -144,13 +144,10 @@ def preset_add(
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, github_hosts=github_provider_hosts()
)
_resolved_from_url = resolve_github_release_asset_api_url(from_url, _open_url)
if _resolved_from_url:
from_url = _resolved_from_url
_preset_extra_headers = {"Accept": "application/octet-stream"}

View File

@@ -1010,12 +1010,7 @@ class WorkflowEngine:
value = float(value)
if value == int(value):
value = int(value)
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.
except (ValueError, TypeError):
msg = f"Input {name!r} expected a number, got {value!r}."
raise ValueError(msg) from None
elif input_type == "boolean":

View File

@@ -146,69 +146,6 @@ 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.
@@ -222,12 +159,11 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
"""
expr = expr.strip()
# 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:
# String literal — check before pipes and operators so quoted strings
# containing | or operator keywords are not mis-parsed.
if (expr.startswith("'") and expr.endswith("'")) or (
expr.startswith('"') and expr.endswith('"')
):
return expr[1:-1]
# Handle pipe filters
@@ -292,33 +228,29 @@ 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)'. 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)
# 'a or b and c' is evaluated as 'a or (b and c)'.
if " or " in expr:
parts = expr.split(" or ", 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
return bool(left) or bool(right)
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)
if " and " in expr:
parts = expr.split(" and ", 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
return bool(left) and bool(right)
if expr.startswith("not "):
inner = _evaluate_simple_expression(expr[4:].strip(), namespace)
return not bool(inner)
# Comparison operators (order matters — check multi-char ops first). Split at
# the first top-level occurrence so an operator inside a quoted operand is
# ignored.
# Comparison operators (order matters — check multi-char ops first)
for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "):
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 in expr:
parts = expr.split(op, 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
if op == "==":
return left == right
if op == "!=":
@@ -359,10 +291,7 @@ 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 _split_top_level_commas(inner)
]
items = [_evaluate_simple_expression(i.strip(), namespace) for i in inner.split(",")]
return items
# Variable reference (dot-path)

View File

@@ -45,7 +45,6 @@ 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
@@ -229,7 +228,6 @@ 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

View File

@@ -66,7 +66,6 @@ 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
@@ -364,5 +363,4 @@ 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

View File

@@ -49,7 +49,6 @@ 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
@@ -252,7 +251,6 @@ 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

View File

@@ -46,7 +46,6 @@ 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
@@ -148,5 +147,4 @@ 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

View File

@@ -49,7 +49,6 @@ 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
@@ -267,6 +266,5 @@ 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

View File

@@ -45,7 +45,6 @@ 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
@@ -193,7 +192,6 @@ 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

View File

@@ -53,7 +53,6 @@ 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
@@ -92,7 +91,6 @@ 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

View File

@@ -50,7 +50,6 @@ 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
@@ -254,7 +253,6 @@ 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

View File

@@ -54,7 +54,6 @@ 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
@@ -112,7 +111,6 @@ 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

View File

@@ -46,7 +46,6 @@ 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
@@ -101,5 +100,4 @@ 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

View File

@@ -298,24 +298,6 @@ class TestCreateFeatureBash:
assert data["BRANCH_NAME"] == "001-user-auth"
assert data["FEATURE_NUM"] == "001"
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)
@@ -444,21 +426,6 @@ class TestCreateFeaturePowerShell:
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "001-user-auth"
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")

View File

@@ -1,211 +0,0 @@
"""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": "<!-- SPECKIT START -->",
"end": "<!-- SPECKIT 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

View File

@@ -67,22 +67,6 @@ class TestCatalogURLValidation:
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

View File

@@ -539,16 +539,8 @@ class TestClaudeDisableModelInvocation:
class TestClaudeForkContext:
"""Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS."""
def test_no_commands_fork_by_default(self):
"""FORK_CONTEXT_COMMANDS is empty: no command opts into context: fork.
``analyze`` was removed (#3185) because its verbose report defeated the
purpose of forking and compounded context overhead across repeated runs.
"""
assert FORK_CONTEXT_COMMANDS == {}
def test_analyze_skill_does_not_fork(self, tmp_path):
"""speckit-analyze must run in the main session, not a forked subagent (#3185)."""
def test_analyze_skill_runs_in_forked_subagent(self, tmp_path):
"""speckit-analyze must opt into context: fork + agent."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")
@@ -557,10 +549,10 @@ class TestClaudeForkContext:
content = analyze_skill.read_text(encoding="utf-8")
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert "context" not in parsed
assert "agent" not in parsed
assert parsed.get("context") == "fork"
assert parsed.get("agent") == "general-purpose"
def test_no_skills_fork(self, tmp_path):
def test_other_skills_do_not_fork(self, tmp_path):
"""Skills not in FORK_CONTEXT_COMMANDS must not get context: fork."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
@@ -582,39 +574,60 @@ class TestClaudeForkContext:
f"{f.parent.name}: must not have agent frontmatter"
)
def test_post_process_no_fork_for_skills(self):
"""With FORK_CONTEXT_COMMANDS empty, post_process must not add context/agent."""
def test_fork_flags_inside_frontmatter(self, tmp_path):
"""context/agent must appear in the frontmatter, not in the body."""
i = get_integration("claude")
for name in ("speckit-analyze", "speckit-plan"):
content = f'---\nname: "{name}"\ndescription: "x"\n---\n\nBody\n'
result = i.post_process_skill_content(content)
parsed = yaml.safe_load(result.split("---", 2)[1])
assert "context" not in parsed
assert "agent" not in parsed
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
content = analyze_skill.read_text(encoding="utf-8")
parts = content.split("---", 2)
assert len(parts) >= 3
frontmatter = parts[1]
body = parts[2]
assert "context: fork" in frontmatter
assert "agent: general-purpose" in frontmatter
assert "context: fork" not in body
assert "agent: general-purpose" not in body
def test_fork_mechanism_injects_when_configured(self, monkeypatch):
"""The injection mechanism still works for any command added to
FORK_CONTEXT_COMMANDS, even though none ships enabled by default."""
import specify_cli.integrations.claude as claude_mod
def test_fork_injection_idempotent(self, tmp_path):
"""Re-running setup must not duplicate the fork frontmatter keys."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")
i.setup(tmp_path, m, script_type="sh")
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
content = analyze_skill.read_text(encoding="utf-8")
assert content.count("context: fork") == 1
assert content.count("agent: general-purpose") == 1
monkeypatch.setitem(
claude_mod.FORK_CONTEXT_COMMANDS,
"analyze",
{"context": "fork", "agent": "general-purpose"},
)
def test_fork_context_injected_via_post_process(self):
"""Preset/extension generators call post_process_skill_content directly,
bypassing setup(); fork context must be injected there too."""
i = get_integration("claude")
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
result = i.post_process_skill_content(content)
parts = result.split("---", 2)
parsed = yaml.safe_load(parts[1])
parsed = yaml.safe_load(result.split("---", 2)[1])
assert parsed.get("context") == "fork"
assert parsed.get("agent") == "general-purpose"
# Flags must land in the frontmatter, not the body.
assert "context: fork" in parts[1]
assert "context: fork" not in parts[2]
# Re-running must not duplicate the injected keys.
twice = i.post_process_skill_content(result)
assert result == twice
assert parsed.get("argument-hint") == ARGUMENT_HINTS["analyze"]
def test_post_process_no_fork_for_other_skills(self):
"""Skills not in FORK_CONTEXT_COMMANDS must not gain context/agent."""
i = get_integration("claude")
content = '---\nname: "speckit-plan"\ndescription: "x"\n---\n\nBody\n'
result = i.post_process_skill_content(content)
parsed = yaml.safe_load(result.split("---", 2)[1])
assert "context" not in parsed
assert "agent" not in parsed
def test_post_process_fork_idempotent(self):
"""Re-running post_process must not duplicate fork frontmatter keys."""
i = get_integration("claude")
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
once = i.post_process_skill_content(content)
twice = i.post_process_skill_content(once)
assert once == twice
assert twice.count("context: fork") == 1
assert twice.count("agent: general-purpose") == 1

View File

@@ -1,7 +1,5 @@
"""Tests for CodebuddyIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
@@ -11,12 +9,3 @@ class TestCodebuddyIntegration(MarkdownIntegrationTests):
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".codebuddy/commands"
CONTEXT_FILE = "CODEBUDDY.md"
def test_install_url_points_to_official_cli_install_docs(self):
integration = get_integration(self.KEY)
assert integration is not None
assert (
integration.config["install_url"]
== "https://www.codebuddy.cn/docs/cli/installation"
)

View File

@@ -1,42 +1,18 @@
"""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_context_file,
_migrate_legacy_kimi_dotted_skills,
_migrate_legacy_kimi_skills_dir,
)
from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
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-code/"
FOLDER = ".kimi/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".kimi-code/skills"
CONTEXT_FILE = "AGENTS.md"
REGISTRAR_DIR = ".kimi/skills"
CONTEXT_FILE = "KIMI.md"
class TestKimiOptions:
@@ -127,32 +103,12 @@ class TestKimiLegacyMigration:
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")
old_skills_dir = tmp_path / ".kimi" / "skills"
new_skills_dir = tmp_path / ".kimi-code" / "skills"
legacy = old_skills_dir / "speckit.oldcmd"
skills_dir = tmp_path / ".kimi" / "skills"
legacy = skills_dir / "speckit.oldcmd"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Legacy\n")
@@ -160,409 +116,9 @@ class TestKimiLegacyMigration:
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
assert not legacy.exists()
assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
# New skills from templates should also exist
assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists()
class TestKimiContextFileMigration:
"""KIMI.md → AGENTS.md migration under --migrate-legacy."""
def test_setup_migrate_legacy_moves_kimi_md_user_content(self, tmp_path):
i = get_integration("kimi")
kimi_md = tmp_path / "KIMI.md"
kimi_md.write_text(
"# Project context\n\n"
"<!-- SPECKIT START -->\n"
"old managed section\n"
"<!-- SPECKIT END -->\n\n"
"Keep this user note.\n"
)
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
agents_md = tmp_path / "AGENTS.md"
assert agents_md.exists()
content = agents_md.read_text(encoding="utf-8")
assert "Keep this user note." in content
assert "old managed section" not in content
assert "<!-- SPECKIT START -->" in content
assert not kimi_md.exists()
def test_setup_migrate_legacy_removes_empty_kimi_md(self, tmp_path):
i = get_integration("kimi")
kimi_md = tmp_path / "KIMI.md"
kimi_md.write_text(
"<!-- SPECKIT START -->\n"
"only managed section\n"
"<!-- SPECKIT END -->\n"
)
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
assert (tmp_path / "AGENTS.md").exists()
assert not kimi_md.exists()
def test_setup_migrate_legacy_appends_to_existing_agents_md(self, tmp_path):
i = get_integration("kimi")
agents_md = tmp_path / "AGENTS.md"
agents_md.write_text("# Existing AGENTS.md\n\nExisting note.\n")
kimi_md = tmp_path / "KIMI.md"
kimi_md.write_text("# Kimi context\n\nKimi-specific note.\n")
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
content = agents_md.read_text(encoding="utf-8")
assert "Existing note." in content
assert "Kimi-specific note." in content
assert "<!-- SPECKIT START -->" in content
assert not kimi_md.exists()
def test_setup_migrate_legacy_uses_custom_context_markers(self, tmp_path):
"""Migration respects context_markers from agent-context extension config."""
i = get_integration("kimi")
config_dir = tmp_path / ".specify" / "extensions" / "agent-context"
config_dir.mkdir(parents=True)
(config_dir / "agent-context-config.yml").write_text(
"context_file: AGENTS.md\n"
"context_markers:\n"
" start: '<!-- CUSTOM START -->'\n"
" end: '<!-- CUSTOM END -->'\n"
)
kimi_md = tmp_path / "KIMI.md"
kimi_md.write_text(
"# Project context\n\n"
"<!-- CUSTOM START -->\n"
"old managed section\n"
"<!-- CUSTOM END -->\n\n"
"Keep this user note.\n"
)
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
agents_md = tmp_path / "AGENTS.md"
assert agents_md.exists()
content = agents_md.read_text(encoding="utf-8")
assert "Keep this user note." in content
assert "old managed section" not in content
assert "<!-- CUSTOM START -->" in content
assert "<!-- CUSTOM END -->" in content
assert "<!-- SPECKIT START -->" not in content
assert not kimi_md.exists()
def test_setup_migrate_legacy_skipped_when_agent_context_disabled(
self, tmp_path
):
"""A disabled agent-context extension opts out of KIMI.md migration."""
i = get_integration("kimi")
registry = tmp_path / ".specify" / "extensions" / ".registry"
registry.parent.mkdir(parents=True)
registry.write_text('{"extensions": {"agent-context": {"enabled": false}}}')
kimi_md = tmp_path / "KIMI.md"
kimi_md.write_text("# Kimi context\n\nKeep this user note.\n")
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
# Opted-out project: KIMI.md is left untouched and AGENTS.md is not
# created/modified by the migration.
assert kimi_md.is_file()
assert kimi_md.read_text() == "# Kimi context\n\nKeep this user note.\n"
assert not (tmp_path / "AGENTS.md").exists()
def test_context_migration_skips_corrupted_single_marker(self, tmp_path):
"""A KIMI.md with only a start marker is left untouched (no leak)."""
project = tmp_path
kimi_md = project / "KIMI.md"
kimi_md.write_text(
"# Notes\n\n"
"<!-- SPECKIT START -->\n"
"dangling managed content\n"
)
result = _migrate_legacy_kimi_context_file(project)
assert result is False
# KIMI.md untouched; managed block never copied into AGENTS.md.
assert kimi_md.is_file()
assert "dangling managed content" in kimi_md.read_text()
assert not (project / "AGENTS.md").exists()
def test_context_migration_skips_unreadable_kimi_md(self, tmp_path):
"""Non-UTF-8 KIMI.md is skipped instead of raising during setup."""
project = tmp_path
kimi_md = project / "KIMI.md"
kimi_md.write_bytes(b"\xff\xfe invalid utf-8 \xa6\n")
result = _migrate_legacy_kimi_context_file(project)
assert result is False
assert kimi_md.is_file()
assert not (project / "AGENTS.md").exists()
def test_context_migration_skips_when_agents_md_is_directory(self, tmp_path):
"""An AGENTS.md that exists as a directory is skipped, not written to."""
project = tmp_path
(project / "AGENTS.md").mkdir()
kimi_md = project / "KIMI.md"
kimi_md.write_text("# Notes\n\nKeep this.\n")
result = _migrate_legacy_kimi_context_file(project)
assert result is False
# KIMI.md is preserved and the directory is untouched.
assert kimi_md.is_file()
assert (project / "AGENTS.md").is_dir()
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()
def test_context_migration_does_not_write_through_symlinked_agents_md(
self, tmp_path
):
# A sensitive file outside the project that a malicious AGENTS.md
# symlink points at. Migration must never overwrite it.
outside = tmp_path / "outside"
outside.mkdir()
secret = outside / "secret.txt"
secret.write_text("original secret\n")
project = tmp_path / "project"
project.mkdir()
_symlink_or_skip(project / "AGENTS.md", secret)
(project / "KIMI.md").write_text("# Notes\n\nKeep this.\n")
result = _migrate_legacy_kimi_context_file(project)
# The outside file must not be overwritten through the symlink.
assert secret.read_text() == "original secret\n"
# KIMI.md is preserved so the user can migrate manually.
assert (project / "KIMI.md").is_file()
assert result is False
def test_context_migration_does_not_follow_symlinked_kimi_md(self, tmp_path):
# A symlinked KIMI.md (source) must not be followed/consumed.
outside = tmp_path / "outside"
outside.mkdir()
external = outside / "external.md"
external.write_text("# external\n")
project = tmp_path / "project"
project.mkdir()
_symlink_or_skip(project / "KIMI.md", external)
result = _migrate_legacy_kimi_context_file(project)
assert result is False
# The external file and the symlink are left intact.
assert external.read_text() == "# external\n"
assert (project / "KIMI.md").is_symlink()
assert not (project / "AGENTS.md").exists()
assert (skills_dir / "speckit-specify" / "SKILL.md").exists()
class TestKimiNextSteps:

View File

@@ -1812,7 +1812,7 @@ class TestIntegrationSwitch:
assert result.exit_code == 0, f"extension add failed: {result.output}"
# Verify git extension skills exist for kimi
kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md"
kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md"
assert kimi_git_feature.exists(), "Git extension skill should exist for kimi"
result = _run_in_project(project, [

View File

@@ -226,17 +226,17 @@ class TestAgentConfigConsistency:
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-code/"
assert AGENT_CONFIG["kimi"]["folder"] == ".kimi/"
assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills"
assert AGENT_CONFIG["kimi"]["requires_cli"] is True
def test_kimi_in_extension_registrar(self):
"""Extension command registrar should include kimi using .kimi-code/skills and SKILL.md."""
"""Extension command registrar should include kimi using .kimi/skills and SKILL.md."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "kimi" in cfg
kimi_cfg = cfg["kimi"]
assert kimi_cfg["dir"] == ".kimi-code/skills"
assert kimi_cfg["dir"] == ".kimi/skills"
assert kimi_cfg["extension"] == "/SKILL.md"
def test_agent_config_includes_kimi(self):

View File

@@ -900,45 +900,3 @@ class TestFetchLatestReleaseTagDelegation:
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")

View File

@@ -16,10 +16,8 @@ import platform
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 (
@@ -40,10 +38,6 @@ from specify_cli.extensions import (
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."""
@@ -1943,7 +1937,7 @@ Agent __AGENT__
@pytest.mark.parametrize("agent_name,skills_path", [
("codex", ".agents/skills"),
("kimi", ".kimi-code/skills"),
("kimi", ".kimi/skills"),
("claude", ".claude/skills"),
("cursor-agent", ".cursor/skills"),
("trae", ".trae/skills"),
@@ -5382,7 +5376,7 @@ class TestExtensionAddCLI:
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(_MINIMAL_ZIP_BYTES)), \
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(b"zip-bytes")), \
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \
patch.object(ExtensionRegistry, "get", return_value={}):
result = runner.invoke(
@@ -5450,98 +5444,6 @@ class TestExtensionAddCLI:
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"<!DOCTYPE html><html>Sign in</html>"),
), \
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"),
[
@@ -5619,7 +5521,7 @@ class TestExtensionAddCLI:
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(_MINIMAL_ZIP_BYTES)), \
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(b"zip-bytes")), \
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip):
result = runner.invoke(
app,
@@ -5628,7 +5530,7 @@ class TestExtensionAddCLI:
)
assert result.exit_code == 0
assert installed["zip_bytes"] == _MINIMAL_ZIP_BYTES
assert installed["zip_bytes"] == b"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()
@@ -7378,36 +7280,3 @@ class TestExtensionForceCLI:
)
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"]

View File

@@ -188,117 +188,3 @@ class TestResolveGitHubReleaseAssetApiUrl:
)
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"]

View File

@@ -1,41 +0,0 @@
"""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<ref>\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"
)

View File

@@ -17,11 +17,9 @@ import tempfile
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
@@ -1424,26 +1422,6 @@ class TestPresetCatalog:
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")
@@ -3785,16 +3763,12 @@ class TestPresetSkills:
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-named skill dirs.
This exercises legacy *naming* (``speckit.specify``) under the current
``.kimi-code/`` base — distinct from the legacy ``.kimi/`` *location*.
"""
"""Preset overrides should still target legacy dotted Kimi skill directories."""
self._write_init_options(project_dir, ai="kimi")
skills_dir = project_dir / ".kimi-code" / "skills"
skills_dir = project_dir / ".kimi" / "skills"
self._create_skill(skills_dir, "speckit.specify", body="untouched")
(project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True)
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
@@ -3811,10 +3785,10 @@ class TestPresetSkills:
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-code" / "skills"
skills_dir = project_dir / ".kimi" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched")
(project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True)
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
install_self_test_preset(manager)
@@ -3831,7 +3805,7 @@ class TestPresetSkills:
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-code" / "skills"
skills_dir = project_dir / ".kimi" / "skills"
skills_dir.mkdir(parents=True, exist_ok=True)
preset_dir = temp_dir / "kimi-new-skill"
@@ -3880,9 +3854,9 @@ class TestPresetSkills:
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-code" / "skills"
skills_dir = project_dir / ".kimi" / "skills"
self._create_skill(skills_dir, "speckit-specify", body="untouched")
(project_dir / ".kimi-code" / "commands").mkdir(parents=True, exist_ok=True)
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
preset_dir = temp_dir / "kimi-placeholder-override"
preset_dir.mkdir()
@@ -4774,69 +4748,6 @@ class TestPresetAddFromUrlResolution:
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 <ghes-release-url>' 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."""
@@ -6106,36 +6017,3 @@ 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"]

View File

@@ -224,25 +224,3 @@ 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

View File

@@ -240,17 +240,6 @@ class TestSequentialBranch:
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)
@@ -275,19 +264,6 @@ class TestSequentialBranch:
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):
@@ -296,42 +272,6 @@ class TestSequentialBranchPowerShell:
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"
# ── check_feature_branch Tests ───────────────────────────────────────────────

View File

@@ -268,60 +268,6 @@ class TestExpressions:
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_filter_default(self):
from specify_cli.workflows.expressions import evaluate_expression
from specify_cli.workflows.base import StepContext
@@ -2846,47 +2792,6 @@ steps:
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.
@@ -5554,137 +5459,6 @@ steps:
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 <ghes-release-url>' 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 <id>' 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."""