Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
272eabdea4 chore: bump version to 0.11.9 2026-06-26 17:22:47 +00:00
129 changed files with 2642 additions and 2013 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..."

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

@@ -75,6 +75,7 @@ class WindsurfIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".windsurf/rules/specify-rules.md"
```
**TOML agent (Gemini):**
@@ -100,6 +101,7 @@ class GeminiIntegration(TomlIntegration):
"args": "{{args}}",
"extension": ".toml",
}
context_file = "GEMINI.md"
```
**Skills agent (Codex):**
@@ -127,6 +129,7 @@ class CodexIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
@@ -147,6 +150,7 @@ class CodexIntegration(SkillsIntegration):
| `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name |
| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` |
| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` |
| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) |
**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`).
@@ -171,11 +175,9 @@ def _register_builtins() -> None:
### 4. Context file behavior
The Specify CLI carries **no agent-context state whatsoever**. Integration classes do **not** declare a `context_file`, and the CLI never creates, updates, removes, resolves, or migrates a context/instruction file (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`, …). New integrations add nothing for context handling.
Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate.
Managing the "Spec Kit" section in the context file is fully owned by the bundled `agent-context` extension (`extensions/agent-context/`), which is a **full opt-in**: `specify init` does not install it. A user adds/enables it through the standard extension verbs, after which the extension's own bundled scripts maintain the context section. When the extension is absent or disabled, nothing in Spec Kit touches the context file.
The extension reads its own config file at `.specify/extensions/agent-context/agent-context-config.yml`:
The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`:
```yaml
# Path to the coding agent context file managed by this extension
@@ -187,10 +189,10 @@ context_markers:
end: "<!-- SPECKIT END -->"
```
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly.
- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run.
- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly — both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth.
Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run.
Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts — the `agent-context` extension is fully generic.
@@ -399,6 +401,7 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
2. Extracts title and description from frontmatter
3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt)
4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping
5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there
## Branch Naming Convention
@@ -463,7 +466,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag
## Common Pitfalls
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
2. **Reintroducing context handling into the CLI**: The opt-in `agent-context` extension owns everything about context files — including the per-agent default mapping in `agent-context-defaults.json`. Integration classes must **not** declare a `context_file`, and no CLI code should read, write, resolve, or migrate context files. All context-file logic lives in `.specify/extensions/agent-context/` and its bundled scripts.
2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally.
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.
5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added.

View File

@@ -2,37 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.12.0] - 2026-06-29
### Changed
- feat: make agent-context extension a full opt-in (#3097)
- docs(workflows): add the built-in 'init' step type to the Step Types table (#3234)
- fix(workflows): gate validate() must not crash on non-string options (#3233)
- fix(workflows): make pipe-filter detection quote-aware in expressions (#3232)
- fix(workflows): reject a fan-in wait_for that names an unknown step at validation (#3225)
- fix(scripts): warn when spec template is missing in create-new-feature.ps1 (parity with bash) (#3230)
- fix(scripts): count subdirectory-only dirs as non-empty in PowerShell (parity with bash) (#3137)
- fix(scripts): drop HAS_GIT from PowerShell git-extension output (parity with bash) (#3195)
- Update Product Spec Extension to v1.0.1 (#3226)
- chore: release 0.11.10, begin 0.11.11.dev0 development (#3240)
## [0.11.10] - 2026-06-29
### Changed
- fix(extensions): apply GHES auth and resolve release assets for `extension add --from` (#3217)
- fix(pi): repoint install_url to @earendil-works/pi-coding-agent (#3169) (#3214)
- fix(catalogs): reject host-less catalog URLs in base and preset validators (#3210)
- fix: update CodeBuddy install docs URL (#3187)
- fix(workflows): reject infinite number-input default instead of raising OverflowError (#3199)
- fix(scripts): emit 'Copied plan template' status in setup-plan.ps1 (parity with bash) (#3198)
- fix(workflows): make expression operator/literal parsing quote-aware (#3197)
- fix(scripts): honor explicit -Number 0 in PowerShell create-new-feature (parity with bash) (#3196)
- Add community bundle submission path (#3162)
- Docs: Document /speckit.converge command (#3181)
- chore: release 0.11.9, begin 0.11.10.dev0 development (#3189)
## [0.11.9] - 2026-06-26
### Changed

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

@@ -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

@@ -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.plan -> /speckit.checklist -> /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` (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.
### Step 1: Install Specify
@@ -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

@@ -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

@@ -262,7 +262,6 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
| `command` | Invoke a Spec Kit command (e.g., `speckit.plan`) |
| `prompt` | Send an arbitrary prompt to the AI coding agent |
| `shell` | Execute a shell command and capture output |
| `init` | Bootstrap a project (like `specify init`) |
| `gate` | Pause for human approval before continuing |
| `if` | Conditional branching (then/else) |
| `switch` | Multi-branch dispatch on an expression |

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

@@ -6,17 +6,15 @@ It owns the lifecycle of the managed section delimited by the configurable start
## Why an extension?
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users:
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users:
- **Choose whether to install it at all** — `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — the bundled scripts honor the `context_markers` value.
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` both the Python layer and the bundled scripts honor the same `context_markers` value.
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator — `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
## Commands
The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
| Command | Description |
|---------|-------------|
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
@@ -42,7 +40,7 @@ context_markers:
end: "<!-- SPECKIT END -->"
```
- `context_file` — the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted.
- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
@@ -64,4 +62,5 @@ pip install pyyaml
specify extension disable agent-context
```
When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out.
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out.

View File

@@ -1,42 +0,0 @@
{
"_comment": "Default coding agent context file per integration, owned by the agent-context extension. Used to self-seed agent-context-config.yml when it declares no context_file/context_files. Keyed by the Spec Kit integration key recorded in .specify/init-options.json. This mapping is independent of the Specify CLI by design.",
"agents": {
"agy": "AGENTS.md",
"amp": "AGENTS.md",
"auggie": ".augment/rules/specify-rules.md",
"bob": "AGENTS.md",
"claude": "CLAUDE.md",
"cline": ".clinerules/specify-rules.md",
"codebuddy": "CODEBUDDY.md",
"codex": "AGENTS.md",
"copilot": ".github/copilot-instructions.md",
"cursor-agent": ".cursor/rules/specify-rules.mdc",
"devin": "AGENTS.md",
"firebender": ".firebender/rules/specify-rules.mdc",
"forge": "AGENTS.md",
"gemini": "GEMINI.md",
"generic": "AGENTS.md",
"goose": "AGENTS.md",
"hermes": "AGENTS.md",
"iflow": "IFLOW.md",
"junie": ".junie/AGENTS.md",
"kilocode": ".kilocode/rules/specify-rules.md",
"kimi": "AGENTS.md",
"kiro-cli": "AGENTS.md",
"lingma": ".lingma/rules/specify-rules.md",
"omp": "AGENTS.md",
"opencode": "AGENTS.md",
"pi": "AGENTS.md",
"qodercli": "QODER.md",
"qwen": "QWEN.md",
"roo": ".roo/rules/specify-rules.md",
"rovodev": "AGENTS.md",
"shai": "SHAI.md",
"tabnine": "TABNINE.md",
"trae": ".trae/rules/project_rules.md",
"vibe": "AGENTS.md",
"windsurf": ".windsurf/rules/specify-rules.md",
"zcode": "ZCODE.md",
"zed": "AGENTS.md"
}
}

View File

@@ -59,7 +59,7 @@ case "$(uname -s 2>/dev/null || true)" in
esac
# Parse extension config once; emit context files as JSON, followed by marker strings.
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" "$PROJECT_ROOT" <<'PY'
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY'
import json
import sys
try:
@@ -95,67 +95,24 @@ def get_str(obj, *keys):
context_files = []
seen_context_files = set()
case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin"))
def add_context_file(value):
if not isinstance(value, str):
return
candidate = value.strip()
if not candidate:
return
key = candidate.casefold() if case_insensitive else candidate
if key in seen_context_files:
return
context_files.append(candidate)
seen_context_files.add(key)
raw_files = data.get("context_files")
if isinstance(raw_files, list):
for value in raw_files:
add_context_file(value)
if not isinstance(value, str):
continue
candidate = value.strip()
if not candidate:
continue
key = candidate.casefold() if case_insensitive else candidate
if key in seen_context_files:
continue
context_files.append(candidate)
seen_context_files.add(key)
if not context_files:
add_context_file(get_str(data, "context_file"))
if not context_files:
# Self-seed: the agent-context extension owns its lifecycle, so when its
# own config declares no target it derives one from the active integration
# recorded in init-options.json, using the extension's OWN bundled mapping
# (agent-context-defaults.json). This is independent of the Specify CLI by
# design — nothing here imports specify_cli.
project_root = sys.argv[3] if len(sys.argv) > 3 else "."
integration_key = ""
try:
with open(
f"{project_root}/.specify/init-options.json", "r", encoding="utf-8"
) as fh:
opts = json.load(fh)
if isinstance(opts, dict):
value = opts.get("integration") or opts.get("ai") or ""
integration_key = value if isinstance(value, str) else ""
except Exception:
integration_key = ""
if integration_key:
defaults_path = (
f"{project_root}/.specify/extensions/agent-context/"
"agent-context-defaults.json"
)
mapping = {}
try:
with open(defaults_path, "r", encoding="utf-8") as fh:
loaded = json.load(fh)
agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {}
mapping = agents if isinstance(agents, dict) else {}
except Exception:
print(
"agent-context: unable to read %s; cannot self-seed the context "
"file. Set 'context_file' in the extension config." % defaults_path,
file=sys.stderr,
)
mapping = {}
add_context_file(mapping.get(integration_key, "") or "")
if not context_files:
print(
"agent-context: no default context file is known for integration "
"'%s'. Set 'context_file' in the extension config to choose one."
% integration_key,
file=sys.stderr,
)
raw_file = get_str(data, "context_file")
candidate = raw_file.strip()
if candidate:
context_files.append(candidate)
print(json.dumps(context_files))
print(get_str(data, "context_markers", "start"))
print(get_str(data, "context_markers", "end"))
@@ -338,58 +295,11 @@ for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
mkdir -p "$(dirname "$CTX_PATH")"
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
import os
import re
import sys
import sys, os
ctx_path, start, end, section_path = sys.argv[1:5]
with open(section_path, "r", encoding="utf-8") as fh:
section = fh.read().rstrip("\n") + "\n"
def ensure_mdc_frontmatter(content):
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.
Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with
``alwaysApply: true``. Prepend it when missing, or repair the value while
preserving any existing frontmatter comments/formatting.
"""
leading_ws = len(content) - len(content.lstrip())
leading = content[:leading_ws]
stripped = content[leading_ws:]
if not stripped.startswith("---"):
return "---\nalwaysApply: true\n---\n\n" + content
match = re.match(
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
stripped,
re.DOTALL,
)
if not match:
return "---\nalwaysApply: true\n---\n\n" + content
opening, fm_text, closing, sep, rest = match.groups()
newline = "\r\n" if "\r\n" in opening else "\n"
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text):
return content
if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
fm_text = re.sub(
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$",
r"\1alwaysApply: true\2",
fm_text,
count=1,
)
elif fm_text.strip():
fm_text = fm_text + newline + "alwaysApply: true"
else:
fm_text = "alwaysApply: true"
return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"
if os.path.exists(ctx_path):
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
content = fh.read()
@@ -419,8 +329,6 @@ else:
new_content = section
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
if ctx_path.casefold().endswith(".mdc"):
new_content = ensure_mdc_frontmatter(new_content)
with open(ctx_path, "wb") as fh:
fh.write(new_content.encode("utf-8"))
PY

View File

@@ -20,56 +20,6 @@ param(
[string]$PlanPath
)
function Add-MdcFrontmatter {
<#
Ensure .mdc content has YAML frontmatter with alwaysApply: true.
Cursor only auto-loads .mdc rule files that carry frontmatter with
alwaysApply: true. Prepend it when missing, or repair the value while
preserving any existing frontmatter comments/formatting.
#>
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
$leading = ''
$stripped = $Content
$m = [regex]::Match($Content, '^\s*')
if ($m.Success) {
$leading = $m.Value
$stripped = $Content.Substring($m.Length)
}
if (-not $stripped.StartsWith('---')) {
return "---`nalwaysApply: true`n---`n`n" + $Content
}
$fm = [regex]::Match($stripped, '^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)', [System.Text.RegularExpressions.RegexOptions]::Singleline)
if (-not $fm.Success) {
return "---`nalwaysApply: true`n---`n`n" + $Content
}
$opening = $fm.Groups[1].Value
$fmText = $fm.Groups[2].Value
$closing = $fm.Groups[3].Value
$sep = $fm.Groups[4].Value
$rest = $fm.Groups[5].Value
$newline = if ($opening.Contains("`r`n")) { "`r`n" } else { "`n" }
if ([regex]::IsMatch($fmText, '(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$')) {
return $Content
}
if ([regex]::IsMatch($fmText, '(?m)^[ \t]*alwaysApply[ \t]*:')) {
$alwaysApplyRegex = [regex]'(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$'
$fmText = $alwaysApplyRegex.Replace($fmText, '${1}alwaysApply: true${2}', 1)
} elseif ($fmText.Trim()) {
$fmText = $fmText + $newline + 'alwaysApply: true'
} else {
$fmText = 'alwaysApply: true'
}
return "$leading$opening$fmText$closing$sep$rest"
}
function Get-ConfigValue {
param(
[AllowNull()][object]$Object,
@@ -300,43 +250,6 @@ foreach ($ContextFile in $ContextFiles) {
}
}
$ContextFiles = $dedupedContextFiles
if ($ContextFiles.Count -eq 0) {
# Self-seed: the agent-context extension owns its lifecycle, so when its
# own config declares no target it derives one from the active integration
# recorded in init-options.json, using the extension's OWN bundled mapping
# (agent-context-defaults.json). Independent of the Specify CLI by design.
$initOptionsPath = Join-Path $ProjectRoot '.specify/init-options.json'
if (Test-Path -LiteralPath $initOptionsPath) {
try {
$initOpts = Get-Content -LiteralPath $initOptionsPath -Raw | ConvertFrom-Json -ErrorAction Stop
$integrationKey = $null
if ($initOpts.PSObject.Properties['integration'] -and $initOpts.integration) {
$integrationKey = [string]$initOpts.integration
} elseif ($initOpts.PSObject.Properties['ai'] -and $initOpts.ai) {
$integrationKey = [string]$initOpts.ai
}
if ($integrationKey) {
$defaultsPath = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-defaults.json'
if (Test-Path -LiteralPath $defaultsPath) {
$defaults = Get-Content -LiteralPath $defaultsPath -Raw | ConvertFrom-Json -ErrorAction Stop
$derived = $null
if ($defaults.PSObject.Properties['agents'] -and $defaults.agents.PSObject.Properties[$integrationKey]) {
$derived = [string]$defaults.agents.PSObject.Properties[$integrationKey].Value
}
if ($derived -and -not [string]::IsNullOrWhiteSpace($derived)) {
$ContextFiles += $derived.Trim()
} else {
Write-Warning ("agent-context: no default context file is known for integration '{0}'; set 'context_file' in the extension config to choose one." -f $integrationKey)
}
} else {
Write-Warning ("agent-context: unable to read {0}; cannot self-seed the context file. Set 'context_file' in the extension config." -f $defaultsPath)
}
}
} catch {
# Non-fatal: fall through to the nothing-to-do guard below.
}
}
}
if ($ContextFiles.Count -eq 0) {
Write-Warning 'agent-context: context_files/context_file not set in extension config; nothing to do.'
exit 0
@@ -498,9 +411,6 @@ foreach ($ContextFile in $ContextFiles) {
}
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
if ($ContextFile -match '\.mdc$') {
$newContent = Add-MdcFrontmatter -Content $newContent
}
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "agent-context: updated $ContextFile"

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-29T00:00:00Z",
"updated_at": "2026-06-24T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -2501,8 +2501,8 @@
"id": "product",
"description": "Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs.",
"author": "d0whc3r",
"version": "1.0.1",
"download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v1.0.1/product-1.0.1.zip",
"version": "0.8.3",
"download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v0.8.3/product-0.8.3.zip",
"repository": "https://github.com/d0whc3r/spec-kit-product",
"homepage": "https://d0whc3r.github.io/spec-kit-product/",
"documentation": "https://github.com/d0whc3r/spec-kit-product/wiki",
@@ -2514,7 +2514,7 @@
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 3,
"commands": 4,
"hooks": 3
},
"tags": [
@@ -2538,7 +2538,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-26T00:00:00Z",
"updated_at": "2026-06-29T00:00:00Z"
"updated_at": "2026-06-01T00:00:00Z"
},
"product-forge": {
"name": "Product Forge",

View File

@@ -400,10 +400,8 @@ if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
# $hasGit is computed for branch-creation logic only; it is intentionally not
# emitted so this output contract matches the bash twin: BRANCH_NAME and
# FEATURE_NUM, plus DRY_RUN (added just below) on dry runs.
if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
}
@@ -411,6 +409,7 @@ if ($Json) {
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}

View File

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

@@ -209,13 +209,7 @@ function Test-FileExists {
function Test-DirHasFiles {
param([string]$Path, [string]$Description)
# A directory counts as non-empty when Get-ChildItem returns any entry
# (files or subdirectories) -- matching the JSON contracts checks in
# check-prerequisites.ps1 / setup-tasks.ps1, and treating a directory whose
# only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as
# non-empty like bash check_dir. Filtering out subdirectories would
# mis-report such a directory as empty.
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
Write-Output " [OK] $Description"
return $true
} else {

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
}
@@ -211,10 +207,6 @@ if (-not $DryRun) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
} else {
# Match the bash twin (create-new-feature.sh): warn on stderr that no
# spec template was found before creating an empty spec file, so the
# missing-template signal is not silently swallowed on Windows.
[Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file")
New-Item -ItemType File -Path $specFile -Force | Out-Null
}
}

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

@@ -262,9 +262,85 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
console.print(f" - {f}")
# ---------------------------------------------------------------------------
# Skills directory helpers
# Agent-context extension config helpers
# ---------------------------------------------------------------------------
_AGENT_CTX_EXT_CONFIG = (
Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml"
)
def _load_agent_context_config(project_root: Path) -> dict[str, Any]:
"""Load the agent-context extension config, returning defaults on failure."""
from .integrations.base import IntegrationBase
defaults: dict[str, Any] = {
"context_file": "",
"context_files": [],
"context_markers": {
"start": IntegrationBase.CONTEXT_MARKER_START,
"end": IntegrationBase.CONTEXT_MARKER_END,
},
}
path = project_root / _AGENT_CTX_EXT_CONFIG
if not path.exists():
return defaults
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, yaml.YAMLError):
return defaults
if not isinstance(raw, dict):
return defaults
return raw
def _save_agent_context_config(
project_root: Path, config: dict[str, Any]
) -> None:
"""Persist *config* to the agent-context extension config file."""
path = project_root / _AGENT_CTX_EXT_CONFIG
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False), encoding="utf-8")
def _update_agent_context_config_file(
project_root: Path,
context_file: str | None,
*,
preserve_markers: bool = True,
preserve_context_files: bool = True,
) -> None:
"""Update the agent-context extension config with *context_file*.
When *preserve_markers* is True (default), any existing
``context_markers`` values are kept unchanged so user customisations
survive integration changes and reinit. When False, the default
markers are written unconditionally.
When *preserve_context_files* is True (default), an existing
``context_files`` list is kept unchanged, including an empty list. This
lets projects opt into updating multiple agent context files while still
preserving the legacy singular ``context_file`` value for compatibility.
"""
from .integrations.base import IntegrationBase
cfg = _load_agent_context_config(project_root)
cfg["context_file"] = context_file or ""
existing_context_files = cfg.get("context_files")
if preserve_context_files:
cfg["context_files"] = (
existing_context_files if isinstance(existing_context_files, list) else []
)
else:
cfg.pop("context_files", None)
if not preserve_markers or not isinstance(cfg.get("context_markers"), dict):
cfg["context_markers"] = {
"start": IntegrationBase.CONTEXT_MARKER_START,
"end": IntegrationBase.CONTEXT_MARKER_END,
}
_save_agent_context_config(project_root, cfg)
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
"""Resolve the agent-specific skills directory.

View File

@@ -433,6 +433,37 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
# Resolve __CONTEXT_FILE__ from the agent-context extension config.
# When disabled, ignore stale context_files but keep the singular
# context_file value so generated commands still point at the agent
# context file managed before the extension was disabled.
from .integrations.base import IntegrationBase
# Local import: _load_agent_context_config lives in __init__.py which
# imports agents.py, so a top-level import would be circular.
from . import _load_agent_context_config
ac_cfg = _load_agent_context_config(project_root)
extension_enabled = IntegrationBase._agent_context_extension_enabled(
project_root
)
if extension_enabled:
context_files = IntegrationBase._resolve_context_file_values(
project_root,
ac_cfg,
legacy_context_file=init_opts.get("context_file"),
)
else:
context_files = IntegrationBase._resolve_context_file_values(
project_root,
ac_cfg,
legacy_context_file=init_opts.get("context_file"),
include_context_files=False,
validate=False,
)
context_file = IntegrationBase._format_context_file_values(context_files)
body = body.replace("__CONTEXT_FILE__", context_file)
return CommandRegistrar.rewrite_project_relative_paths(body)
def _convert_argument_placeholder(

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

@@ -18,6 +18,7 @@ from .._agent_config import (
SCRIPT_TYPE_CHOICES,
)
from .._assets import (
_locate_bundled_extension,
_locate_bundled_preset,
_locate_bundled_workflow,
get_speckit_version,
@@ -170,6 +171,7 @@ def register(app: typer.Typer) -> None:
from .. import (
_install_shared_infra_or_exit,
_print_cli_warning,
_update_agent_context_config_file,
ensure_executable_scripts,
save_init_options,
)
@@ -374,6 +376,7 @@ def register(app: typer.Typer) -> None:
("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"),
("workflow", "Install bundled workflow"),
("agent-context", "Install agent-context extension"),
("final", "Finalize"),
]:
tracker.add(key, label)
@@ -504,6 +507,47 @@ def register(app: typer.Typer) -> None:
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)
# --- agent-context extension (bundled, auto-installed) ---
# Installed after init-options.json is written so that skill
# registration can read ai_skills + integration key.
try:
from ..extensions import ExtensionManager as _ExtMgr
bundled_ac = _locate_bundled_extension("agent-context")
if bundled_ac:
ac_mgr = _ExtMgr(project_path)
if ac_mgr.registry.is_installed("agent-context"):
tracker.complete("agent-context", "already installed")
else:
ac_mgr.install_from_directory(
bundled_ac, get_speckit_version()
)
tracker.complete("agent-context", "extension installed")
else:
from ..extensions import REINSTALL_COMMAND as _ac_reinstall
tracker.error(
"agent-context",
f"bundled extension not found — installation may be "
f"incomplete. Run: {_ac_reinstall}",
)
except Exception as ac_err:
sanitized_ac = str(ac_err).replace("\n", " ").strip()
tracker.error(
"agent-context",
f"extension install failed: {sanitized_ac[:120]}",
)
# Write context_file to the agent-context extension config
# AFTER the extension install (which copies the template config
# with an empty context_file).
if resolved_integration.context_file:
_update_agent_context_config_file(
project_path,
resolved_integration.context_file,
preserve_markers=True,
)
ensure_executable_scripts(project_path, tracker=tracker)
if preset:

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

@@ -117,6 +117,11 @@ class {class_name}({template.base_class}):
"args": "{template.args}",
"extension": "{template.extension}",
}}
context_file = "AGENTS.md"
# Default to False so the generated boilerplate passes the registry
# contract out of the box: multi-install-safe integrations must each have a
# distinct context_file, and the placeholder above ("AGENTS.md") collides
# with the existing codex integration. Opt in once you pick a unique one.
multi_install_safe = False
'''
@@ -150,6 +155,7 @@ def test_metadata():
assert integration.registrar_config["format"] == "{template.registrar_format}"
assert integration.registrar_config["args"] == "{template.args}"
assert integration.registrar_config["extension"] == "{template.extension}"
assert integration.context_file == "AGENTS.md"
assert integration.multi_install_safe is False
'''
@@ -268,7 +274,7 @@ def scaffold_integration(
next_steps = (
f"Register {class_name} in src/specify_cli/integrations/__init__.py.",
"Review config metadata, install_url, requires_cli, and multi_install_safe.",
"Review config metadata, install_url, requires_cli, context_file, and multi_install_safe.",
f"Run pytest tests/integrations/test_integration_{package_name}.py -v.",
)
return IntegrationScaffoldResult(

View File

@@ -103,17 +103,38 @@ def _refresh_init_options_speckit_version(project_root: Path) -> None:
def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None:
"""Clear active integration keys from init-options.json when they match."""
"""Clear active integration keys from init-options.json when they match.
Also clears ``context_file`` from the agent-context extension config so
no stale path is left behind when the integration is uninstalled.
"""
from .. import (
_AGENT_CTX_EXT_CONFIG,
_update_agent_context_config_file,
load_init_options,
save_init_options,
)
opts = load_init_options(project_root)
has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts)
# Remove legacy fields that older versions may have written.
opts.pop("context_file", None)
opts.pop("context_markers", None)
if opts.get("integration") == integration_key or opts.get("ai") == integration_key:
opts.pop("integration", None)
opts.pop("ai", None)
opts.pop("ai_skills", None)
save_init_options(project_root, opts)
# Clear context_file in the extension config if it already exists.
# Avoid creating the config (and parent dirs) in projects where the
# agent-context extension was never installed.
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
if ext_cfg_path.exists():
_update_agent_context_config_file(
project_root, "", preserve_markers=True, preserve_context_files=False
)
elif has_legacy_context_keys:
save_init_options(project_root, opts)
def _remove_integration_json(project_root: Path) -> None:
@@ -253,13 +274,21 @@ def _update_init_options_for_integration(
integration: Any,
script_type: str | None = None,
) -> None:
"""Update init-options.json to reflect *integration* as the active one.
"""Update init-options.json and the agent-context extension config to
reflect *integration* as the active one.
Agent context/instruction files are owned entirely by the opt-in
agent-context extension, so this function never touches the extension
or its config.
``context_file``, ``context_files``, and ``context_markers`` are stored in the agent-context
extension config (``.specify/extensions/agent-context/agent-context-config.yml``),
not in ``init-options.json``. Existing user-customised markers are
always preserved when the config already exists. Existing ``context_files``
lists are also preserved so projects can keep multi-agent context anchors
during integration switches. Invalid marker values are
silently ignored at runtime by ``_resolve_context_markers()`` which falls
back to the class-level defaults.
"""
from .. import (
_AGENT_CTX_EXT_CONFIG,
_update_agent_context_config_file,
load_init_options,
save_init_options,
)
@@ -267,6 +296,9 @@ def _update_init_options_for_integration(
opts = load_init_options(project_root)
opts["integration"] = integration.key
opts["ai"] = integration.key
# Remove legacy fields if they were written by an older version.
opts.pop("context_file", None)
opts.pop("context_markers", None)
opts["speckit_version"] = _get_speckit_version()
if script_type:
opts["script"] = script_type
@@ -275,6 +307,24 @@ def _update_init_options_for_integration(
else:
opts.pop("ai_skills", None)
# Update the agent-context extension config BEFORE init-options.json
# so a failure here doesn't leave init-options partially updated.
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
if ext_cfg_path.exists():
_update_agent_context_config_file(
project_root,
integration.context_file,
preserve_markers=True,
)
elif integration.context_file:
# Extension config doesn't exist yet (extension not installed).
# Write defaults so scripts have something to read.
_update_agent_context_config_file(
project_root,
integration.context_file,
preserve_markers=False,
)
save_init_options(project_root, opts)

View File

@@ -42,6 +42,7 @@ class AgyIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@staticmethod
def _inject_hook_command_note(content: str) -> str:

View File

@@ -18,3 +18,4 @@ class AmpIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"

View File

@@ -18,4 +18,5 @@ class AuggieIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".augment/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -13,13 +13,14 @@ Provides:
from __future__ import annotations
import json
import os
import re
import shlex
import shutil
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PureWindowsPath
from typing import TYPE_CHECKING, Any
import yaml
@@ -90,9 +91,13 @@ class IntegrationBase(ABC):
And may optionally set:
* ``invoke_separator`` — slash-command separator (defaults to ``"."``)
* ``multi_install_safe`` — declare the integration safe to install
alongside others (defaults to ``False``)
* ``context_file`` — path (relative to project root) of the agent
context/instructions file (e.g. ``"CLAUDE.md"``)
Projects may additionally opt into managing multiple context files by
setting ``context_files`` in the agent-context extension config. The
integration class still declares one default ``context_file`` for backwards
compatibility and command-template rendering.
"""
# -- Must be set by every subclass ------------------------------------
@@ -108,6 +113,9 @@ class IntegrationBase(ABC):
# -- Optional ---------------------------------------------------------
context_file: str | None = None
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
@@ -117,11 +125,16 @@ class IntegrationBase(ABC):
multi_install_safe: bool = False
"""Whether this integration is declared safe to install alongside others.
Safe integrations must use a static, unique agent root and command
directory. Registry tests enforce those invariants for every
Safe integrations must use a static, unique agent root, command directory,
and context file. Registry tests enforce those invariants for every
integration that sets this flag.
"""
# -- Markers for managed context section ------------------------------
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
CONTEXT_MARKER_END = "<!-- SPECKIT END -->"
# -- Public API -------------------------------------------------------
@classmethod
@@ -520,6 +533,498 @@ class IntegrationBase(ABC):
return created
# -- Agent context file management ------------------------------------
@staticmethod
def _ensure_mdc_frontmatter(content: str) -> str:
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.
If frontmatter is missing, prepend it. If frontmatter exists but
``alwaysApply`` is absent or not ``true``, inject/fix it.
Uses string/regex manipulation to preserve comments and formatting
in existing frontmatter.
"""
import re as _re
leading_ws = len(content) - len(content.lstrip())
leading = content[:leading_ws]
stripped = content[leading_ws:]
if not stripped.startswith("---"):
return "---\nalwaysApply: true\n---\n\n" + content
# Match frontmatter block: ---\n...\n---
match = _re.match(
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
stripped,
_re.DOTALL,
)
if not match:
return "---\nalwaysApply: true\n---\n\n" + content
opening, fm_text, closing, sep, rest = match.groups()
newline = "\r\n" if "\r\n" in opening else "\n"
# Already correct?
if _re.search(
r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text
):
return content
# alwaysApply exists but wrong value — fix in place while preserving
# indentation and any trailing inline comment.
if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
fm_text = _re.sub(
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$",
r"\1alwaysApply: true\2",
fm_text,
count=1,
)
elif fm_text.strip():
fm_text = fm_text + newline + "alwaysApply: true"
else:
fm_text = "alwaysApply: true"
return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"
@staticmethod
def _build_context_section(plan_path: str = "") -> str:
"""Build the content for the managed section between markers.
*plan_path* is the project-relative path to the current plan
(e.g. ``"specs/<feature>/plan.md"``). When empty, the section
contains only the generic directive without a concrete path.
"""
lines = [
"For additional context about technologies to be used, project structure,",
"shell commands, and other important information, read the current plan",
]
if plan_path:
lines.append(f"at {plan_path}")
return "\n".join(lines)
@staticmethod
def _agent_context_extension_enabled(project_root: Path) -> bool:
"""Return whether the bundled ``agent-context`` extension is enabled.
The extension is the single source of truth for managing coding
agent context/instruction files (e.g. ``CLAUDE.md``,
``.github/copilot-instructions.md``).
Returns ``True`` (enabled) when:
- the extension registry does not exist (legacy project, backwards
compatibility), or
- the registry has no ``agent-context`` entry (older project layout
predating the extension), or
- the entry is present and not explicitly disabled.
Returns ``False`` only when an entry exists with ``enabled: false``.
"""
registry_path = (
project_root / ".specify" / "extensions" / ".registry"
)
if not registry_path.exists():
return True
try:
data = json.loads(registry_path.read_text(encoding="utf-8"))
except (OSError, ValueError, UnicodeError):
return True
if not isinstance(data, dict):
return True
extensions = data.get("extensions")
if not isinstance(extensions, dict):
return True
entry = extensions.get("agent-context")
if not isinstance(entry, dict):
return True
return entry.get("enabled", True) is not False
@staticmethod
def _context_file_dedupe_key(path: str) -> str:
"""Return the comparison key for context file de-duplication."""
return path.casefold() if os.name == "nt" else path
def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]:
"""Return the (start, end) context markers to use for *project_root*.
Reads ``context_markers.start`` / ``context_markers.end`` from the
agent-context extension config
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present. Falls back to the class-level constants
``CONTEXT_MARKER_START`` / ``CONTEXT_MARKER_END`` when the file is
missing, the section is absent, or the values are not non-empty
strings.
"""
from .._console import console # local import to avoid cycles
start = self.CONTEXT_MARKER_START
end = self.CONTEXT_MARKER_END
config_path = (
project_root
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
try:
raw = config_path.read_text(encoding="utf-8")
cfg = yaml.safe_load(raw)
except (OSError, UnicodeError, ValueError, yaml.YAMLError):
return start, end
markers = cfg.get("context_markers") if isinstance(cfg, dict) else None
if isinstance(markers, dict):
cm_start = markers.get("start")
cm_end = markers.get("end")
s_valid = isinstance(cm_start, str) and cm_start
e_valid = isinstance(cm_end, str) and cm_end
if not s_valid and cm_start is not None:
console.print(
f"[yellow]agent-context: ignoring invalid context_markers.start "
f"({cm_start!r}), using default[/yellow]"
)
if not e_valid and cm_end is not None:
console.print(
f"[yellow]agent-context: ignoring invalid context_markers.end "
f"({cm_end!r}), using default[/yellow]"
)
if s_valid:
start = cm_start # type: ignore[assignment]
if e_valid:
end = cm_end # type: ignore[assignment]
return start, end
@staticmethod
def _validate_context_file_path(project_root: Path, context_file: str) -> str:
"""Return a safe project-relative context file path.
The agent-context scripts reject paths that can escape the project
root; the Python integration path must apply the same guard before
setup or teardown touches context files.
"""
candidate = context_file.strip()
if not candidate:
raise ValueError("agent-context: context file path must not be empty")
win_path = PureWindowsPath(candidate)
if Path(candidate).is_absolute() or win_path.drive or win_path.root:
raise ValueError(
"agent-context: context files must be project-relative paths; "
f"got {candidate!r}"
)
if "\\" in candidate:
raise ValueError(
"agent-context: context files must not contain backslash "
f"separators; got {candidate!r}"
)
parts = [part for part in re.split(r"[\\/]+", candidate) if part]
if ".." in parts:
raise ValueError(
"agent-context: context files must not contain '..' path "
f"segments; got {candidate!r}"
)
root = project_root.resolve()
target = (root / candidate).resolve(strict=False)
try:
target.relative_to(root)
except ValueError as exc:
raise ValueError(
"agent-context: context file path resolves outside the project "
f"root; got {candidate!r}"
) from exc
return candidate
@classmethod
def _resolve_context_file_values(
cls,
project_root: Path,
cfg: dict[str, Any] | None,
*,
fallback_context_file: Any = None,
legacy_context_file: Any = None,
include_context_files: bool = True,
validate: bool = True,
) -> list[str]:
"""Resolve context file config with shared precedence and de-duplication."""
files: list[str] = []
seen: set[str] = set()
def add_context_file(value: Any) -> None:
if not isinstance(value, str):
return
candidate = value.strip()
if not candidate:
return
if validate:
candidate = cls._validate_context_file_path(project_root, candidate)
key = cls._context_file_dedupe_key(candidate)
if key in seen:
return
files.append(candidate)
seen.add(key)
if isinstance(cfg, dict) and include_context_files:
configured = cfg.get("context_files")
if isinstance(configured, list):
for value in configured:
add_context_file(value)
if files:
return files
if isinstance(cfg, dict):
add_context_file(cfg.get("context_file"))
if files:
return files
add_context_file(fallback_context_file)
if files:
return files
add_context_file(legacy_context_file)
return files
@staticmethod
def _format_context_file_values(context_files: list[str]) -> str:
"""Return context file targets as the template display string."""
return ", ".join(context_files)
def _resolve_context_files(self, project_root: Path) -> list[str]:
"""Return project-relative context files managed for *project_root*.
``context_files`` in the agent-context extension config, when present
and non-empty, takes precedence over the config's singular
``context_file``. The integration class default is used only when the
extension config has no context file target.
Raises ``ValueError`` when a configured path can escape the project
root.
"""
config_path = (
project_root
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
try:
raw = config_path.read_text(encoding="utf-8")
cfg = yaml.safe_load(raw)
except (OSError, UnicodeError, ValueError, yaml.YAMLError):
cfg = None
return self._resolve_context_file_values(
project_root,
cfg,
fallback_context_file=self.context_file,
)
def _context_file_display(self, project_root: Path) -> str:
"""Return human-readable context file target(s) for templates."""
if not self._agent_context_extension_enabled(project_root):
from .. import _load_agent_context_config
context_files = self._resolve_context_file_values(
project_root,
_load_agent_context_config(project_root),
fallback_context_file=self.context_file,
include_context_files=False,
validate=False,
)
return context_files[0] if context_files else ""
return self._format_context_file_values(
self._resolve_context_files(project_root)
)
@staticmethod
def _upsert_context_file(
ctx_path: Path,
section: str,
marker_start: str,
marker_end: str,
) -> None:
"""Create or update one managed context section."""
if ctx_path.exists():
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
end_idx = content.find(
marker_end,
start_idx if start_idx != -1 else 0,
)
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
# Replace existing section (include the end marker + newline)
end_of_marker = end_idx + len(marker_end)
# Consume trailing line ending (CRLF or LF)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = content[:start_idx] + section + content[end_of_marker:]
elif start_idx != -1:
# Corrupted: start marker without end — replace from start through EOF
new_content = content[:start_idx] + section
elif end_idx != -1:
# Corrupted: end marker without start — replace BOF through end marker
end_of_marker = end_idx + len(marker_end)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = section + content[end_of_marker:]
else:
# No markers found — append
if content:
if not content.endswith("\n"):
content += "\n"
new_content = content + "\n" + section
else:
new_content = section
# Ensure .mdc files have required YAML frontmatter
if ctx_path.suffix == ".mdc":
new_content = IntegrationBase._ensure_mdc_frontmatter(new_content)
else:
ctx_path.parent.mkdir(parents=True, exist_ok=True)
# Cursor .mdc files require YAML frontmatter to be loaded
if ctx_path.suffix == ".mdc":
new_content = IntegrationBase._ensure_mdc_frontmatter(section)
else:
new_content = section
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
ctx_path.write_bytes(normalized.encode("utf-8"))
def upsert_context_section(
self,
project_root: Path,
plan_path: str = "",
) -> Path | None:
"""Create or update the managed section in the agent context file.
If the context file does not exist it is created with just the
managed section. If it exists, the content between the configured
start/end markers (default ``<!-- SPECKIT START -->`` /
``<!-- SPECKIT END -->``) is replaced, or appended when no markers
are found. Markers are read from the agent-context extension config
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present, falling back to the class-level constants.
Returns the path to the first context file, or ``None`` when no context
files are configured or the ``agent-context`` extension is
disabled.
"""
if not self._agent_context_extension_enabled(project_root):
return None
context_files = self._resolve_context_files(project_root)
if not context_files:
return None
from .._console import console # local import to avoid cycles
console.print(
"[yellow]Deprecation:[/yellow] Inline agent-context updates during "
"integration setup will be disabled in v0.12.0. Context file "
"management has moved to the bundled [bold]agent-context[/bold] "
"extension. Run [cyan]specify extension disable agent-context[/cyan] "
"to opt out early.",
highlight=False,
)
marker_start, marker_end = self._resolve_context_markers(project_root)
section = (
f"{marker_start}\n"
f"{self._build_context_section(plan_path)}\n"
f"{marker_end}\n"
)
first_path: Path | None = None
for context_file in context_files:
ctx_path = project_root / context_file
self._upsert_context_file(ctx_path, section, marker_start, marker_end)
if first_path is None:
first_path = ctx_path
return first_path
def remove_context_section(self, project_root: Path) -> bool:
"""Remove the managed section from the agent context file.
Returns ``True`` if the section was found and removed. If the
file becomes empty (or whitespace-only) after removal it is deleted.
Markers are read from the agent-context extension config
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present, falling back to the class-level constants.
"""
if not self._agent_context_extension_enabled(project_root):
return False
context_files = self._resolve_context_files(project_root)
if not context_files:
return False
marker_start, marker_end = self._resolve_context_markers(project_root)
removed_any = False
for context_file in context_files:
ctx_path = project_root / context_file
if not ctx_path.exists():
continue
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
end_idx = content.find(
marker_end,
start_idx if start_idx != -1 else 0,
)
# Only remove a complete, well-ordered managed section. If either
# marker is missing, leave the file unchanged to avoid deleting
# unrelated user-authored content.
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
continue
removal_start = start_idx
removal_end = end_idx + len(marker_end)
# Consume trailing line ending (CRLF or LF)
if removal_end < len(content) and content[removal_end] == "\r":
removal_end += 1
if removal_end < len(content) and content[removal_end] == "\n":
removal_end += 1
# Also strip a blank line before the section if present
if removal_start > 0 and content[removal_start - 1] == "\n":
if removal_start > 1 and content[removal_start - 2] == "\n":
removal_start -= 1
new_content = content[:removal_start] + content[removal_end:]
# Normalize line endings before comparisons
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
if ctx_path.suffix == ".mdc":
import re
# Delete the file if only YAML frontmatter remains (no body content)
frontmatter_only = re.match(
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
)
if not normalized.strip() or frontmatter_only:
ctx_path.unlink()
removed_any = True
continue
if not normalized.strip():
ctx_path.unlink()
else:
ctx_path.write_bytes(normalized.encode("utf-8"))
removed_any = True
return removed_any
@staticmethod
def resolve_command_refs(content: str, separator: str = ".") -> str:
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
@@ -544,6 +1049,7 @@ class IntegrationBase(ABC):
agent_name: str,
script_type: str,
arg_placeholder: str = "$ARGUMENTS",
context_file: str = "",
invoke_separator: str = ".",
) -> str:
"""Process a raw command template into agent-ready content.
@@ -554,8 +1060,9 @@ class IntegrationBase(ABC):
3. Strip ``scripts:`` section from frontmatter
4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
5. Replace ``__AGENT__`` with *agent_name*
6. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
7. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
6. Replace ``__CONTEXT_FILE__`` with *context_file*
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
8. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
"""
# 1. Extract script command from frontmatter
script_command = ""
@@ -615,7 +1122,10 @@ class IntegrationBase(ABC):
# 5. Replace __AGENT__
content = content.replace("__AGENT__", agent_name)
# 6. Rewrite paths — delegate to the shared implementation in
# 6. Replace __CONTEXT_FILE__
content = content.replace("__CONTEXT_FILE__", context_file)
# 7. Rewrite paths — delegate to the shared implementation in
# CommandRegistrar so extension-local paths are preserved and
# boundary rules stay consistent across the codebase.
from specify_cli.agents import CommandRegistrar
@@ -670,6 +1180,8 @@ class IntegrationBase(ABC):
self.record_file_in_manifest(dst_file, project_root, manifest)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created
@@ -684,9 +1196,11 @@ class IntegrationBase(ABC):
Delegates to ``manifest.uninstall()`` which only removes files
whose hash still matches the recorded value (unless *force*).
Also removes the managed context section from the agent file.
Returns ``(removed, skipped)`` file lists.
"""
self.remove_context_section(project_root)
return manifest.uninstall(project_root, force=force)
# -- Convenience helpers for subclasses -------------------------------
@@ -720,11 +1234,12 @@ class IntegrationBase(ABC):
class MarkdownIntegration(IntegrationBase):
"""Concrete base for integrations that use standard Markdown commands.
Subclasses only need to set ``key``, ``config``, ``registrar_config``.
Everything else is inherited.
Subclasses only need to set ``key``, ``config``, ``registrar_config``
(and optionally ``context_file``). Everything else is inherited.
``setup()`` processes command templates (replacing ``{SCRIPT}``,
``{ARGS}``, ``__AGENT__``, rewriting paths).
``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the
managed context section into the agent context file.
"""
def build_exec_args(
@@ -779,11 +1294,13 @@ class MarkdownIntegration(IntegrationBase):
else "$ARGUMENTS"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
@@ -791,6 +1308,8 @@ class MarkdownIntegration(IntegrationBase):
)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created
@@ -804,7 +1323,8 @@ class TomlIntegration(IntegrationBase):
"""Concrete base for integrations that use TOML command format.
Mirrors ``MarkdownIntegration`` closely: subclasses only need to set
``key``, ``config``, ``registrar_config``. Everything else is inherited.
``key``, ``config``, ``registrar_config`` (and optionally
``context_file``). Everything else is inherited.
``setup()`` processes command templates through the same placeholder
pipeline as ``MarkdownIntegration``, then converts the result to
@@ -980,12 +1500,14 @@ class TomlIntegration(IntegrationBase):
else "{{args}}"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
description = self._extract_description(raw)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
)
_, body = self._split_frontmatter(processed)
toml_content = self._render_toml(description, body)
@@ -995,6 +1517,8 @@ class TomlIntegration(IntegrationBase):
)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created
@@ -1008,7 +1532,8 @@ class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format.
Mirrors ``TomlIntegration`` closely: subclasses only need to set
``key``, ``config``, ``registrar_config``. Everything else is inherited.
``key``, ``config``, ``registrar_config`` (and optionally
``context_file``). Everything else is inherited.
``setup()`` processes command templates through the same placeholder
pipeline as ``MarkdownIntegration``, then converts the result to
@@ -1171,6 +1696,7 @@ class YamlIntegration(IntegrationBase):
else "{{args}}"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -1186,6 +1712,7 @@ class YamlIntegration(IntegrationBase):
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
)
_, body = self._split_frontmatter(processed)
yaml_content = self._render_yaml(
@@ -1197,6 +1724,8 @@ class YamlIntegration(IntegrationBase):
)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created
@@ -1212,8 +1741,8 @@ class SkillsIntegration(IntegrationBase):
Skills use the ``speckit-<name>/SKILL.md`` directory layout following
the `agentskills.io <https://agentskills.io/specification>`_ spec.
Subclasses set ``key``, ``config``, ``registrar_config`` like any
integration. They may also
Subclasses set ``key``, ``config``, ``registrar_config`` (and
optionally ``context_file``) like any integration. They may also
override ``options()`` to declare additional CLI flags (e.g.
``--skills``, ``--migrate-legacy``).
@@ -1358,6 +1887,7 @@ class SkillsIntegration(IntegrationBase):
else "$ARGUMENTS"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -1381,6 +1911,7 @@ class SkillsIntegration(IntegrationBase):
# Process body through the standard template pipeline
processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.
@@ -1427,5 +1958,7 @@ class SkillsIntegration(IntegrationBase):
)
created.append(dst)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created

View File

@@ -18,3 +18,4 @@ class BobIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"

View File

@@ -52,6 +52,7 @@ class ClaudeIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "CLAUDE.md"
multi_install_safe = True
@staticmethod

View File

@@ -70,6 +70,7 @@ class ClineIntegration(MarkdownIntegration):
"format_name": format_cline_command_name,
"invoke_separator": "-",
}
context_file = ".clinerules/specify-rules.md"
invoke_separator = "-"
multi_install_safe = True

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 = {
@@ -18,4 +18,5 @@ class CodebuddyIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "CODEBUDDY.md"
multi_install_safe = True

View File

@@ -26,6 +26,7 @@ class CodexIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
dev_no_symlink = True
multi_install_safe = True

View File

@@ -4,6 +4,7 @@ Copilot has several unique behaviors compared to standard markdown agents:
- Commands use ``.agent.md`` extension (not ``.md``)
- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
- Installs ``.vscode/settings.json`` with prompt file recommendations
- Context file lives at ``.github/copilot-instructions.md``
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
@@ -78,6 +79,7 @@ class _CopilotSkillsHelper(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = ".github/copilot-instructions.md"
class CopilotIntegration(IntegrationBase):
@@ -106,6 +108,7 @@ class CopilotIntegration(IntegrationBase):
"args": "$ARGUMENTS",
"extension": ".agent.md",
}
context_file = ".github/copilot-instructions.md"
# Mutable flag set by setup() — indicates the active scaffolding mode.
_skills_mode: bool = False
@@ -351,12 +354,14 @@ class CopilotIntegration(IntegrationBase):
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
context_file_display = self._context_file_display(project_root)
# 1. Process and write command files as .agent.md
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
@@ -391,6 +396,8 @@ class CopilotIntegration(IntegrationBase):
self.record_file_in_manifest(dst_settings, project_root, manifest)
created.append(dst_settings)
# 4. Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created

View File

@@ -36,6 +36,7 @@ class CursorAgentIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = ".cursor/rules/specify-rules.mdc"
multi_install_safe = True
def build_exec_args(

View File

@@ -30,6 +30,7 @@ class DevinIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,

View File

@@ -3,8 +3,8 @@
Firebender (https://firebender.com/) is an AI coding agent for Android Studio
and IntelliJ. It reads project-local custom slash commands from
``.firebender/commands/*.mdc`` and project rules from ``.firebender/rules/*.mdc``,
so Spec Kit installs its command templates as ``.mdc`` command files. The managed
context section (when used) is owned by the ``agent-context`` extension.
so Spec Kit installs its command templates as ``.mdc`` command files and writes
the managed context section into a ``.firebender/rules/`` rule file.
"""
from ..base import MarkdownIntegration
@@ -25,6 +25,7 @@ class FirebenderIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".mdc",
}
context_file = ".firebender/rules/specify-rules.mdc"
multi_install_safe = True
def command_filename(self, template_name: str) -> str:

View File

@@ -89,6 +89,7 @@ class ForgeIntegration(MarkdownIntegration):
"format_name": format_forge_command_name, # Custom name formatter
"invoke_separator": "-",
}
context_file = "AGENTS.md"
invoke_separator = "-"
def setup(
@@ -127,12 +128,14 @@ class ForgeIntegration(MarkdownIntegration):
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "{{parameters}}")
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
# Process template with standard MarkdownIntegration logic
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)
@@ -149,6 +152,8 @@ class ForgeIntegration(MarkdownIntegration):
)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created

View File

@@ -18,4 +18,5 @@ class GeminiIntegration(TomlIntegration):
"args": "{{args}}",
"extension": ".toml",
}
context_file = "GEMINI.md"
multi_install_safe = True

View File

@@ -31,6 +31,7 @@ class GenericIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:
@@ -118,11 +119,13 @@ class GenericIntegration(MarkdownIntegration):
script_type = opts.get("script_type", "sh")
arg_placeholder = "$ARGUMENTS"
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
@@ -130,5 +133,7 @@ class GenericIntegration(MarkdownIntegration):
)
created.append(dst_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
return created

View File

@@ -18,3 +18,4 @@ class GooseIntegration(YamlIntegration):
"args": "{{args}}",
"extension": ".yaml",
}
context_file = "AGENTS.md"

View File

@@ -50,6 +50,7 @@ class HermesIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
# -- Helpers -----------------------------------------------------------
@@ -113,6 +114,7 @@ class HermesIntegration(SkillsIntegration):
global_skills_dir.mkdir(parents=True, exist_ok=True)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -139,6 +141,7 @@ class HermesIntegration(SkillsIntegration):
self.key,
script_type,
arg_placeholder,
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.
@@ -180,6 +183,8 @@ class HermesIntegration(SkillsIntegration):
skill_file.write_bytes(normalized.encode("utf-8"))
created.append(skill_file)
# Upsert managed context section into the agent context file
self.upsert_context_section(project_root)
# Create project-local marker directory so extension commands
# (e.g. git) can detect Hermes as an active integration.
@@ -199,7 +204,8 @@ class HermesIntegration(SkillsIntegration):
) -> tuple[list[Path], list[Path]]:
"""Uninstall integration files including global Hermes skills.
Removes the project-local marker directory (if empty), delegates to
Removes the managed context section from AGENTS.md, removes the
project-local marker directory (if empty), delegates to
``manifest.uninstall()`` for project-local tracked files, and
removes all ``speckit-*`` skills under ``~/.hermes/skills/``.
@@ -207,6 +213,8 @@ class HermesIntegration(SkillsIntegration):
standard integration behaviour where all files created by the
integration are removed on ``specify integration uninstall``.
"""
# Remove managed context section from AGENTS.md
self.remove_context_section(project_root)
# Delegate to manifest for project-local tracked files (scripts,
# templates, context entries tracked in the manifest).

View File

@@ -18,4 +18,5 @@ class IflowIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "IFLOW.md"
multi_install_safe = True

View File

@@ -18,4 +18,5 @@ class JunieIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".junie/AGENTS.md"
multi_install_safe = True

View File

@@ -18,4 +18,5 @@ class KilocodeIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".kilocode/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -5,7 +5,8 @@ Kimi uses the ``.kimi-code/skills/speckit-<name>/SKILL.md`` layout with
Legacy migration covers projects created before Kimi Code CLI moved to
this layout and handles two distinct changes: the directory move from
``.kimi/`` to ``.kimi-code/``, and the dotted-to-hyphenated skill naming
``.kimi/`` to ``.kimi-code/`` (including the ``KIMI.md`` → ``AGENTS.md``
context file), and the dotted-to-hyphenated skill naming
(``speckit.xxx`` → ``speckit-xxx``).
"""
@@ -15,7 +16,7 @@ import shutil
from pathlib import Path
from typing import Any
from ..base import IntegrationOption, SkillsIntegration
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
@@ -36,6 +37,7 @@ class KimiIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
multi_install_safe = False
def build_command_invocation(self, command_name: str, args: str = "") -> str:
@@ -77,7 +79,9 @@ class KimiIntegration(SkillsIntegration):
default=False,
help=(
"Migrate legacy Kimi installations: "
".kimi/skills/ → .kimi-code/skills/ and speckit.xxx → speckit-xxx"
".kimi/skills/ → .kimi-code/skills/, speckit.xxx → speckit-xxx, "
"and (when the agent-context extension is enabled) "
"KIMI.md user content → AGENTS.md"
),
),
]
@@ -124,6 +128,14 @@ class KimiIntegration(SkillsIntegration):
_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
)
return created
@@ -351,6 +363,112 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool:
)
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.

View File

@@ -26,3 +26,4 @@ class KiroCliIntegration(MarkdownIntegration):
"args": _KIRO_ARG_FALLBACK,
"extension": ".md",
}
context_file = "AGENTS.md"

View File

@@ -27,6 +27,7 @@ class LingmaIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = ".lingma/rules/specify-rules.md"
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -20,6 +20,7 @@ class OmpIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,

View File

@@ -19,6 +19,7 @@ class OpencodeIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,

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 = {
@@ -18,3 +18,4 @@ class PiIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"

View File

@@ -18,4 +18,5 @@ class QodercliIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "QODER.md"
multi_install_safe = True

View File

@@ -18,4 +18,5 @@ class QwenIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "QWEN.md"
multi_install_safe = True

View File

@@ -18,4 +18,5 @@ class RooIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".roo/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -39,6 +39,7 @@ class RovodevIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
# -- CLI dispatch ------------------------------------------------------
@@ -227,7 +228,8 @@ class RovodevIntegration(SkillsIntegration):
) -> list[Path]:
"""Install RovoDev skills, then generate prompt wrappers and manifest.
1. ``SkillsIntegration.setup()`` generates the skill files.
1. ``SkillsIntegration.setup()`` generates skill files and
upserts the context section.
2. Generates prompt wrappers and ``prompts.yml`` for each skill
created in step 1.
"""

View File

@@ -18,4 +18,5 @@ class ShaiIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "SHAI.md"
multi_install_safe = True

View File

@@ -18,4 +18,5 @@ class TabnineIntegration(TomlIntegration):
"args": "{{args}}",
"extension": ".toml",
}
context_file = "TABNINE.md"
multi_install_safe = True

View File

@@ -26,6 +26,7 @@ class TraeIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = ".trae/rules/project_rules.md"
multi_install_safe = True
@classmethod

View File

@@ -28,6 +28,7 @@ class VibeIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -18,4 +18,5 @@ class WindsurfIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = ".windsurf/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -28,6 +28,7 @@ class ZcodeIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "ZCODE.md"
multi_install_safe = True
@classmethod

View File

@@ -27,6 +27,7 @@ class ZedIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
@classmethod
def options(cls) -> list[IntegrationOption]:

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."
)

View File

@@ -296,40 +296,6 @@ def _validate_steps(
f"boolean, got {type(coe).__name__}."
)
# Fan-in: every wait_for id must reference a step declared at or before
# this point. An id not yet seen is either a typo (unknown step) or a
# forward reference (the target runs after this fan-in, so its results
# cannot exist yet) — both are wiring errors that previously surfaced as
# a silent empty result + COMPLETED. A step that is declared but only
# conditionally executed (e.g. inside an if/switch branch) is still
# "seen" here, so a legitimately-empty result at runtime stays valid.
if step_type == "fan-in":
wait_for = step_config.get("wait_for")
if isinstance(wait_for, list):
for wid in wait_for:
if not isinstance(wid, str):
# A non-string entry (e.g. YAML `wait_for: [123]`) can
# never match a real step id, so the join is silently
# empty at runtime — surface it as a wiring error.
errors.append(
f"Fan-in step {step_id!r}: 'wait_for' entries must "
f"be step-id strings, got {type(wid).__name__} "
f"({wid!r})."
)
elif wid == step_id:
# The fan-in's own id is already in seen_ids by now, so
# a self-reference would pass the membership check below
# while still producing an empty join at runtime.
errors.append(
f"Fan-in step {step_id!r}: 'wait_for' references "
f"itself; a fan-in cannot wait for its own results."
)
elif wid not in seen_ids:
errors.append(
f"Fan-in step {step_id!r}: 'wait_for' references "
f"unknown or not-yet-declared step id {wid!r}."
)
# Recursively validate nested steps
for nested_key in ("then", "else", "steps"):
nested = step_config.get(nested_key)
@@ -1044,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

@@ -180,35 +180,6 @@ def _split_top_level_commas(text: str) -> list[str]:
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,21 +193,18 @@ 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. Detect the pipe at the top level only, so a literal
# '|' inside a quoted operand (e.g. `inputs.x == 'a|b'`) or nested brackets is
# not mistaken for a filter separator — mirroring the operator parsing below.
pipe_idx = _find_top_level(expr, "|")
if pipe_idx != -1:
value = _evaluate_simple_expression(expr[:pipe_idx].strip(), namespace)
filter_expr = expr[pipe_idx + 1:].strip()
# Handle pipe filters
if "|" in expr:
parts = expr.split("|", 1)
value = _evaluate_simple_expression(parts[0].strip(), namespace)
filter_expr = parts[1].strip()
# `from_json` is strict: it takes no arguments and tolerates no
# trailing tokens. Match on the leading filter name and require the
@@ -294,33 +262,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 == "!=":

View File

@@ -194,14 +194,7 @@ class GateStep(StepBase):
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
f"'abort', 'skip', or 'retry'."
)
# Only inspect option text when every option is a string; otherwise the
# `o.lower()` below would raise AttributeError on a non-string option
# (already reported above) and break validate_workflow's never-raise contract.
if (
on_reject in ("abort", "retry")
and isinstance(options, list)
and all(isinstance(o, str) for o in options)
):
if on_reject in ("abort", "retry") and isinstance(options, list):
reject_choices = {"reject", "abort"}
if not any(o.lower() in reject_choices for o in options):
errors.append(

View File

@@ -156,11 +156,14 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate
- Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites
- Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase
**Output**: data-model.md, /contracts/*, quickstart.md
4. **Agent context update**:
- Update the plan reference between the `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path)
**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file
## Key rules
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files
- ERROR on gate failures or unresolved clarifications
## Done When

View File

@@ -298,24 +298,6 @@ class TestCreateFeatureBash:
assert data["BRANCH_NAME"] == "001-user-auth"
assert data["FEATURE_NUM"] == "001"
def test_output_omits_has_git_for_parity(self, tmp_path: Path):
"""The bash output contract is {BRANCH_NAME, FEATURE_NUM} (+ DRY_RUN) in JSON
and a BRANCH_NAME:/FEATURE_NUM: text block -- no HAS_GIT key/line. This pins
the canonical contract the PowerShell twin must mirror."""
project = _setup_project(tmp_path)
rj = _run_bash(
"create-new-feature-branch.sh", project,
"--json", "--dry-run", "--short-name", "parity", "Parity feature",
)
assert rj.returncode == 0, rj.stderr
assert "HAS_GIT" not in json.loads(rj.stdout)
rt = _run_bash(
"create-new-feature-branch.sh", project,
"--dry-run", "--short-name", "parity", "Parity feature",
)
assert rt.returncode == 0, rt.stderr
assert "HAS_GIT" not in rt.stdout
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
"""A short word is dropped from the derived branch name unless it appears
as an acronym in UPPERCASE in the description (case-sensitive, must match the
@@ -462,24 +444,6 @@ class TestCreateFeaturePowerShell:
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "001-user-auth"
def test_output_omits_has_git_to_match_bash(self, tmp_path: Path):
"""PowerShell must mirror the bash twin's output contract: neither JSON nor
text output may include HAS_GIT (it is computed internally for branch-creation
logic only). Fails before the fix (PS emitted HAS_GIT), passes after."""
project = _setup_project(tmp_path)
rj = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-Json", "-DryRun", "-ShortName", "parity", "Parity feature",
)
assert rj.returncode == 0, rj.stderr
assert "HAS_GIT" not in json.loads(rj.stdout)
rt = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-DryRun", "-ShortName", "parity", "Parity feature",
)
assert rt.returncode == 0, rt.stderr
assert "HAS_GIT" not in rt.stdout
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
"""PowerShell must match the bash twin: a short word is dropped unless it
appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match)."""

View File

@@ -1,57 +0,0 @@
"""Static guard: the Specify CLI source must contain no agent-context lifecycle code.
The ``agent-context`` extension is a full opt-in and owns its own lifecycle. The
Python codebase (``src/specify_cli/**``) must therefore not reference any of the
removed context-section management helpers, the extension config helpers, the
context markers, or the obsolete deprecation message.
Maps to contract C5 / FR-002 / FR-003 / FR-006 / SC-002 / SC-003.
"""
from __future__ import annotations
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
SRC_ROOT = PROJECT_ROOT / "src" / "specify_cli"
FORBIDDEN_SYMBOLS = [
"upsert_context_section",
"remove_context_section",
"_agent_context_extension_enabled",
"_resolve_context_markers",
"_resolve_context_files",
"_resolve_context_file_values",
"_build_context_section",
"_AGENT_CTX_EXT_CONFIG",
"_load_agent_context_config",
"_save_agent_context_config",
"_update_agent_context_config_file",
"CONTEXT_MARKER_START",
"CONTEXT_MARKER_END",
"agent-context-config",
"agent_context_config",
"__CONTEXT_FILE__",
"_context_file_display",
"Inline agent-context updates",
"v0.12.0",
]
@pytest.fixture(scope="module")
def cli_source_texts() -> list[tuple[str, str]]:
"""Read every CLI source file once, shared across all parametrized cases."""
return [
(str(path.relative_to(PROJECT_ROOT)), path.read_text(encoding="utf-8"))
for path in SRC_ROOT.rglob("*.py")
]
@pytest.mark.parametrize("symbol", FORBIDDEN_SYMBOLS)
def test_symbol_absent_from_cli_source(symbol, cli_source_texts):
offenders = [rel for rel, text in cli_source_texts if symbol in text]
assert not offenders, (
f"Forbidden agent-context symbol {symbol!r} still present in: {offenders}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -20,3 +20,4 @@ class StubIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "STUB.md"

View File

@@ -43,6 +43,7 @@ class TestIntegrationBase:
assert i.key == "stub"
assert i.config["name"] == "Stub Agent"
assert i.registrar_config["format"] == "markdown"
assert i.context_file == "STUB.md"
def test_options_default_empty(self):
assert StubIntegration.options() == []

View File

@@ -77,17 +77,23 @@ class TestInitIntegrationFlag:
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts["integration"] == "copilot"
# init must not leave any legacy agent-context keys in init-options.json
# context_file lives in the agent-context extension config, not init-options.json
assert "context_file" not in opts
# agent-context is fully opt-in: init must not install it or write its config
import yaml as _yaml
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
assert not ext_cfg_path.exists(), "init must not create the agent-context extension config"
assert ext_cfg_path.exists(), "agent-context extension config must be created on init"
ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8"))
assert ext_cfg["context_file"] == ".github/copilot-instructions.md"
assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
# init must not create or manage the agent context file
assert not (project / ".github" / "copilot-instructions.md").exists()
# Context section should be upserted into the copilot instructions file
ctx_file = project / ".github" / "copilot-instructions.md"
assert ctx_file.exists()
ctx_content = ctx_file.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in ctx_content
assert "<!-- SPECKIT END -->" in ctx_content
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
assert shared_manifest.exists()
@@ -1264,6 +1270,7 @@ class TestIntegrationCatalogDiscoveryCLI:
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "BROKEN.md"
def setup(self, project_root, manifest, **kwargs):
raise OSError("setup exploded\nwith context")

View File

@@ -37,6 +37,7 @@ class _ClaudeStub(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "CLAUDE.md"
class _KiroCliStub(SkillsIntegration):
@@ -57,6 +58,7 @@ class _KiroCliStub(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "KIRO.md"
class _NoCliStub(SkillsIntegration):
@@ -77,6 +79,7 @@ class _NoCliStub(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "NOCLI.md"
class _MarkdownAgentStub(MarkdownIntegration):
@@ -99,6 +102,7 @@ class _MarkdownAgentStub(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "MDAGENT.md"
class _TomlAgentStub(TomlIntegration):
@@ -120,6 +124,7 @@ class _TomlAgentStub(TomlIntegration):
"args": "$ARGUMENTS",
"extension": ".toml",
}
context_file = "TOMLAGENT.md"
@pytest.fixture(autouse=True)

View File

@@ -10,6 +10,7 @@ class TestAgyIntegration(SkillsIntegrationTests):
FOLDER = ".agents/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"
CONTEXT_FILE = "AGENTS.md"
def test_options_include_skills_flag(self):
"""Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout."""

View File

@@ -8,3 +8,4 @@ class TestAmpIntegration(MarkdownIntegrationTests):
FOLDER = ".agents/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".agents/commands"
CONTEXT_FILE = "AGENTS.md"

View File

@@ -8,3 +8,4 @@ class TestAuggieIntegration(MarkdownIntegrationTests):
FOLDER = ".augment/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".augment/commands"
CONTEXT_FILE = ".augment/rules/specify-rules.md"

View File

@@ -1,8 +1,8 @@
"""Reusable test mixin for standard MarkdownIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
and ``REGISTRAR_DIR``, then inherits all verification logic from
``MarkdownIntegrationTests``.
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
logic from ``MarkdownIntegrationTests``.
"""
import os
@@ -21,12 +21,14 @@ class MarkdownIntegrationTests:
FOLDER: str — e.g. ".claude/"
COMMANDS_SUBDIR: str — e.g. "commands"
REGISTRAR_DIR: str — e.g. ".claude/commands"
CONTEXT_FILE: str — e.g. "CLAUDE.md"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
CONTEXT_FILE: str
# -- Registration -----------------------------------------------------
@@ -54,6 +56,10 @@ class MarkdownIntegrationTests:
assert i.registrar_config["args"] == "$ARGUMENTS"
assert i.registrar_config["extension"] == ".md"
def test_context_file(self):
i = get_integration(self.KEY)
assert i.context_file == self.CONTEXT_FILE
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
@@ -95,18 +101,19 @@ class MarkdownIntegrationTests:
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.
Agent context files are owned entirely by the opt-in agent-context
extension, so the core plan command must not reference one.
"""
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan command must reference this integration's context file."""
i = get_integration(self.KEY)
if not i.context_file:
return
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
assert plan_file.exists(), f"Plan file {plan_file} not created"
content = plan_file.read_text(encoding="utf-8")
assert i.context_file in content, (
f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}"
)
assert "__CONTEXT_FILE__" not in content, (
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
)
@@ -142,32 +149,35 @@ class MarkdownIntegrationTests:
assert modified_file.exists()
assert modified_file in skipped
# -- Context file ownership (extension-owned, opt-in) -----------------
# -- Context section ---------------------------------------------------
def test_setup_does_not_write_context_section(self, tmp_path):
"""Setup must not create or manage any agent context file — that is
owned entirely by the opt-in agent-context extension."""
def test_setup_upserts_context_section(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text, (
f"Setup wrote a managed context section into {path} for {self.KEY}"
)
if i.context_file:
ctx_path = tmp_path / i.context_file
assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}"
content = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in content
assert "<!-- SPECKIT END -->" in content
assert "read the current plan" in content
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
"""A user-authored context file must survive setup + teardown untouched."""
def test_teardown_removes_context_section(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
ctx_path = tmp_path / "AGENTS.md"
original = "# My Rules\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
i.setup(tmp_path, m)
m.save()
i.teardown(tmp_path, m)
assert ctx_path.read_text(encoding="utf-8") == original
if i.context_file:
ctx_path = tmp_path / i.context_file
# Add user content around the section
content = ctx_path.read_text(encoding="utf-8")
ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8")
i.teardown(tmp_path, m)
remaining = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" not in remaining
assert "<!-- SPECKIT END -->" not in remaining
assert "# My Rules" in remaining
# -- CLI integration flag -------------------------------------------------
@@ -215,10 +225,35 @@ class MarkdownIntegrationTests:
commands = sorted(cmd_dir.glob("speckit.*"))
assert len(commands) > 0, f"No command files in {cmd_dir}"
def test_init_options_includes_context_file(self, tmp_path):
"""agent-context extension config must include context_file for the active integration."""
import yaml
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"opts-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
i = get_integration(self.KEY)
assert ext_cfg.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
)
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"agent-context.update",
"analyze", "clarify", "constitution", "converge", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
]
@@ -258,7 +293,19 @@ class MarkdownIntegrationTests:
files.append(".specify/workflows/speckit/workflow.yml")
files.append(".specify/workflows/workflow-registry.json")
# Bundled agent-context extension
files.append(".specify/extensions.yml")
files.append(".specify/extensions/.registry")
files.append(".specify/extensions/agent-context/README.md")
files.append(".specify/extensions/agent-context/agent-context-config.yml")
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
files.append(".specify/extensions/agent-context/extension.yml")
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
# Agent context file (if set)
if i.context_file:
files.append(i.context_file)
return sorted(files)

View File

@@ -1,8 +1,8 @@
"""Reusable test mixin for standard SkillsIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
and ``REGISTRAR_DIR``, then inherits all verification logic from
``SkillsIntegrationTests``.
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
logic from ``SkillsIntegrationTests``.
Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely,
adapted for the ``speckit-<name>/SKILL.md`` skills layout.
@@ -26,12 +26,14 @@ class SkillsIntegrationTests:
FOLDER: str — e.g. ".agents/"
COMMANDS_SUBDIR: str — e.g. "skills"
REGISTRAR_DIR: str — e.g. ".agents/skills"
CONTEXT_FILE: str — e.g. "AGENTS.md"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
CONTEXT_FILE: str
# -- Registration -----------------------------------------------------
@@ -59,6 +61,10 @@ class SkillsIntegrationTests:
assert i.registrar_config["args"] == "$ARGUMENTS"
assert i.registrar_config["extension"] == "/SKILL.md"
def test_context_file(self):
i = get_integration(self.KEY)
assert i.context_file == self.CONTEXT_FILE
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
@@ -216,18 +222,19 @@ class SkillsIntegrationTests:
body = parts[2].strip() if len(parts) >= 3 else ""
assert len(body) > 0, f"{f} has empty body"
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
"""The generated plan skill must not carry a context-file placeholder.
Agent context files are owned entirely by the opt-in agent-context
extension, so the core plan skill must not reference one.
"""
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan skill must reference this integration's context file."""
i = get_integration(self.KEY)
if not i.context_file:
return
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md"
assert plan_file.exists(), f"Plan skill {plan_file} not created"
content = plan_file.read_text(encoding="utf-8")
assert i.context_file in content, (
f"Plan skill should reference {i.context_file!r} but it was not found"
)
assert "__CONTEXT_FILE__" not in content, (
"Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
)
@@ -276,32 +283,34 @@ class SkillsIntegrationTests:
assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed"
# -- Context file ownership (extension-owned, opt-in) -----------------
# -- Context section ---------------------------------------------------
def test_setup_does_not_write_context_section(self, tmp_path):
"""Setup must not create or manage any agent context file — that is
owned entirely by the opt-in agent-context extension."""
def test_setup_upserts_context_section(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text, (
f"Setup wrote a managed context section into {path} for {self.KEY}"
)
if i.context_file:
ctx_path = tmp_path / i.context_file
assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}"
content = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in content
assert "<!-- SPECKIT END -->" in content
assert "read the current plan" in content
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
"""A user-authored context file must survive setup + teardown untouched."""
def test_teardown_removes_context_section(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
ctx_path = tmp_path / "AGENTS.md"
original = "# My Rules\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
i.setup(tmp_path, m)
m.save()
i.teardown(tmp_path, m)
assert ctx_path.read_text(encoding="utf-8") == original
if i.context_file:
ctx_path = tmp_path / i.context_file
content = ctx_path.read_text(encoding="utf-8")
ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8")
i.teardown(tmp_path, m)
remaining = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" not in remaining
assert "<!-- SPECKIT END -->" not in remaining
assert "# My Rules" in remaining
# -- CLI integration flag -------------------------------------------------
@@ -347,9 +356,9 @@ class SkillsIntegrationTests:
skills_dir = i.skills_dest(project)
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
def test_init_does_not_create_agent_context_config(self, tmp_path):
"""agent-context is opt-in: init must not auto-install the extension
or write its config."""
def test_init_options_includes_context_file(self, tmp_path):
"""agent-context extension config must include context_file for the active integration."""
import yaml
from typer.testing import CliRunner
from specify_cli import app
@@ -366,7 +375,11 @@ class SkillsIntegrationTests:
os.chdir(old_cwd)
assert result.exit_code == 0
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
assert not ext_cfg_path.exists()
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
i = get_integration(self.KEY)
assert ext_cfg.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
)
# -- IntegrationOption ------------------------------------------------
@@ -393,6 +406,8 @@ class SkillsIntegrationTests:
# Skill files (core commands)
for cmd in self._SKILL_COMMANDS:
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
# Extension-installed skill (agent-context)
files.append(f"{skills_prefix}/speckit-agent-context-update/SKILL.md")
# Integration metadata
files += [
".specify/init-options.json",
@@ -431,6 +446,18 @@ class SkillsIntegrationTests:
".specify/workflows/speckit/workflow.yml",
".specify/workflows/workflow-registry.json",
]
# Bundled agent-context extension
files.append(".specify/extensions.yml")
files.append(".specify/extensions/.registry")
files.append(".specify/extensions/agent-context/README.md")
files.append(".specify/extensions/agent-context/agent-context-config.yml")
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
files.append(".specify/extensions/agent-context/extension.yml")
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
# Agent context file (if set)
if i.context_file:
files.append(i.context_file)
return sorted(files)
def test_complete_file_inventory_sh(self, tmp_path):

View File

@@ -1,8 +1,8 @@
"""Reusable test mixin for standard TomlIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
and ``REGISTRAR_DIR``, then inherits all verification logic from
``TomlIntegrationTests``.
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
logic from ``TomlIntegrationTests``.
Mirrors ``MarkdownIntegrationTests`` closely — same test structure,
adapted for TOML output format.
@@ -27,12 +27,14 @@ class TomlIntegrationTests:
FOLDER: str — e.g. ".gemini/"
COMMANDS_SUBDIR: str — e.g. "commands"
REGISTRAR_DIR: str — e.g. ".gemini/commands"
CONTEXT_FILE: str — e.g. "GEMINI.md"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
CONTEXT_FILE: str
# -- Registration -----------------------------------------------------
@@ -60,6 +62,10 @@ class TomlIntegrationTests:
assert i.registrar_config["args"] == "{{args}}"
assert i.registrar_config["extension"] == ".toml"
def test_context_file(self):
i = get_integration(self.KEY)
assert i.context_file == self.CONTEXT_FILE
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
@@ -305,18 +311,19 @@ class TomlIntegrationTests:
raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc
assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key"
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.
Agent context files are owned entirely by the opt-in agent-context
extension, so the core plan command must not reference one.
"""
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan command must reference this integration's context file."""
i = get_integration(self.KEY)
if not i.context_file:
return
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
assert plan_file.exists(), f"Plan file {plan_file} not created"
content = plan_file.read_text(encoding="utf-8")
assert i.context_file in content, (
f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}"
)
assert "__CONTEXT_FILE__" not in content, (
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
)
@@ -352,32 +359,34 @@ class TomlIntegrationTests:
assert modified_file.exists()
assert modified_file in skipped
# -- Context file ownership (extension-owned, opt-in) -----------------
# -- Context section ---------------------------------------------------
def test_setup_does_not_write_context_section(self, tmp_path):
"""Setup must not create or manage any agent context file — that is
owned entirely by the opt-in agent-context extension."""
def test_setup_upserts_context_section(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text, (
f"Setup wrote a managed context section into {path} for {self.KEY}"
)
if i.context_file:
ctx_path = tmp_path / i.context_file
assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}"
content = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in content
assert "<!-- SPECKIT END -->" in content
assert "read the current plan" in content
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
"""A user-authored context file must survive setup + teardown untouched."""
def test_teardown_removes_context_section(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
ctx_path = tmp_path / "AGENTS.md"
original = "# My Rules\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
i.setup(tmp_path, m)
m.save()
i.teardown(tmp_path, m)
assert ctx_path.read_text(encoding="utf-8") == original
if i.context_file:
ctx_path = tmp_path / i.context_file
content = ctx_path.read_text(encoding="utf-8")
ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8")
i.teardown(tmp_path, m)
remaining = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" not in remaining
assert "<!-- SPECKIT END -->" not in remaining
assert "# My Rules" in remaining
# -- CLI integration flag -------------------------------------------------
@@ -445,10 +454,35 @@ class TomlIntegrationTests:
commands = sorted(cmd_dir.glob("speckit.*.toml"))
assert len(commands) > 0, f"No command files in {cmd_dir}"
def test_init_options_includes_context_file(self, tmp_path):
"""agent-context extension config must include context_file for the active integration."""
import yaml
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"opts-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
i = get_integration(self.KEY)
assert ext_cfg.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
)
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"agent-context.update",
"analyze",
"clarify",
"constitution",
@@ -510,7 +544,19 @@ class TomlIntegrationTests:
files.append(".specify/workflows/speckit/workflow.yml")
files.append(".specify/workflows/workflow-registry.json")
# Bundled agent-context extension
files.append(".specify/extensions.yml")
files.append(".specify/extensions/.registry")
files.append(".specify/extensions/agent-context/README.md")
files.append(".specify/extensions/agent-context/agent-context-config.yml")
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
files.append(".specify/extensions/agent-context/extension.yml")
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
# Agent context file (if set)
if i.context_file:
files.append(i.context_file)
return sorted(files)

View File

@@ -1,8 +1,8 @@
"""Reusable test mixin for standard YamlIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
and ``REGISTRAR_DIR``, then inherits all verification logic from
``YamlIntegrationTests``.
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
logic from ``YamlIntegrationTests``.
Mirrors ``TomlIntegrationTests`` closely — same test structure,
adapted for YAML recipe output format.
@@ -26,12 +26,14 @@ class YamlIntegrationTests:
FOLDER: str — e.g. ".goose/"
COMMANDS_SUBDIR: str — e.g. "recipes"
REGISTRAR_DIR: str — e.g. ".goose/recipes"
CONTEXT_FILE: str — e.g. "AGENTS.md"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
CONTEXT_FILE: str
# -- Registration -----------------------------------------------------
@@ -59,6 +61,10 @@ class YamlIntegrationTests:
assert i.registrar_config["args"] == "{{args}}"
assert i.registrar_config["extension"] == ".yaml"
def test_context_file(self):
i = get_integration(self.KEY)
assert i.context_file == self.CONTEXT_FILE
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
@@ -184,18 +190,19 @@ class YamlIntegrationTests:
assert "scripts:" not in parsed["prompt"]
assert "---" not in parsed["prompt"]
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The generated plan command must not carry a context-file placeholder.
Agent context files are owned entirely by the opt-in agent-context
extension, so the core plan command must not reference one.
"""
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan command must reference this integration's context file."""
i = get_integration(self.KEY)
if not i.context_file:
return
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
assert plan_file.exists(), f"Plan file {plan_file} not created"
content = plan_file.read_text(encoding="utf-8")
assert i.context_file in content, (
f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}"
)
assert "__CONTEXT_FILE__" not in content, (
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
)
@@ -231,32 +238,34 @@ class YamlIntegrationTests:
assert modified_file.exists()
assert modified_file in skipped
# -- Context file ownership (extension-owned, opt-in) -----------------
# -- Context section ---------------------------------------------------
def test_setup_does_not_write_context_section(self, tmp_path):
"""Setup must not create or manage any agent context file — that is
owned entirely by the opt-in agent-context extension."""
def test_setup_upserts_context_section(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text, (
f"Setup wrote a managed context section into {path} for {self.KEY}"
)
if i.context_file:
ctx_path = tmp_path / i.context_file
assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}"
content = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in content
assert "<!-- SPECKIT END -->" in content
assert "read the current plan" in content
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
"""A user-authored context file must survive setup + teardown untouched."""
def test_teardown_removes_context_section(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
ctx_path = tmp_path / "AGENTS.md"
original = "# My Rules\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
i.setup(tmp_path, m)
m.save()
i.teardown(tmp_path, m)
assert ctx_path.read_text(encoding="utf-8") == original
if i.context_file:
ctx_path = tmp_path / i.context_file
content = ctx_path.read_text(encoding="utf-8")
ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8")
i.teardown(tmp_path, m)
remaining = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" not in remaining
assert "<!-- SPECKIT END -->" not in remaining
assert "# My Rules" in remaining
# -- CLI integration flag -------------------------------------------------
@@ -324,10 +333,35 @@ class YamlIntegrationTests:
commands = sorted(cmd_dir.glob("speckit.*.yaml"))
assert len(commands) > 0, f"No command files in {cmd_dir}"
def test_init_options_includes_context_file(self, tmp_path):
"""agent-context extension config must include context_file for the active integration."""
import yaml
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"opts-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
i = get_integration(self.KEY)
assert ext_cfg.get("context_file") == i.context_file, (
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
)
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"agent-context.update",
"analyze",
"clarify",
"constitution",
@@ -389,7 +423,19 @@ class YamlIntegrationTests:
files.append(".specify/workflows/speckit/workflow.yml")
files.append(".specify/workflows/workflow-registry.json")
# Bundled agent-context extension
files.append(".specify/extensions.yml")
files.append(".specify/extensions/.registry")
files.append(".specify/extensions/agent-context/README.md")
files.append(".specify/extensions/agent-context/agent-context-config.yml")
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
files.append(".specify/extensions/agent-context/extension.yml")
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
# Agent context file (if set)
if i.context_file:
files.append(i.context_file)
return sorted(files)

View File

@@ -8,3 +8,4 @@ class TestBobIntegration(MarkdownIntegrationTests):
FOLDER = ".bob/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".bob/commands"
CONTEXT_FILE = "AGENTS.md"

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

@@ -1,5 +1,6 @@
"""Tests for ClaudeIntegration."""
import codecs
import json
import os
from pathlib import Path
@@ -33,6 +34,10 @@ class TestClaudeIntegration:
assert integration.registrar_config["args"] == "$ARGUMENTS"
assert integration.registrar_config["extension"] == "/SKILL.md"
def test_context_file(self):
integration = get_integration("claude")
assert integration.context_file == "CLAUDE.md"
def test_setup_creates_skill_files(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
@@ -71,30 +76,57 @@ class TestClaudeIntegration:
)
assert "Prüfe Konformität" in rendered
def test_setup_does_not_write_context_section(self, tmp_path):
"""The CLI no longer manages the agent context file — that is owned by
the opt-in agent-context extension. Setup must not create or touch it."""
def test_setup_upserts_context_section(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text
ctx_path = tmp_path / integration.context_file
assert ctx_path.exists()
content = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in content
assert "<!-- SPECKIT END -->" in content
assert "read the current plan" in content
def test_teardown_does_not_touch_existing_context_file(self, tmp_path):
"""A user-authored context file is left intact on teardown."""
def test_upsert_context_section_strips_bom(self, tmp_path):
"""Existing context file with UTF-8 BOM must be cleaned up on upsert."""
integration = get_integration("claude")
ctx_path = tmp_path / "CLAUDE.md"
original = "# CLAUDE.md\n\nUser content.\n"
ctx_path.write_text(original, encoding="utf-8")
ctx_path = tmp_path / integration.context_file
manifest = IntegrationManifest("claude", tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
integration.teardown(tmp_path, manifest)
# Write a file that starts with a UTF-8 BOM (as the old PowerShell script did)
bom = codecs.BOM_UTF8
ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n")
assert ctx_path.read_text(encoding="utf-8") == original
integration.upsert_context_section(tmp_path)
result = ctx_path.read_bytes()
assert not result.startswith(bom), "BOM must be stripped after upsert"
content = result.decode("utf-8")
assert "<!-- SPECKIT START -->" in content
assert "Some existing content." in content
def test_remove_context_section_strips_bom(self, tmp_path):
"""remove_context_section must clean BOM from context file on Windows-authored files."""
integration = get_integration("claude")
ctx_path = tmp_path / integration.context_file
marker_content = (
"# CLAUDE.md\n\n"
"<!-- SPECKIT START -->\n"
"For additional context about technologies to be used, project structure,\n"
"shell commands, and other important information, read the current plan\n"
"<!-- SPECKIT END -->\n"
)
ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8"))
result = integration.remove_context_section(tmp_path)
assert result is True
assert ctx_path.exists(), "File should exist (non-empty content remains)"
remaining = ctx_path.read_bytes()
assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove"
assert b"<!-- SPECKIT" not in remaining
assert b"# CLAUDE.md" in remaining
def test_integration_flag_creates_skill_files_cli(self, tmp_path):
from typer.testing import CliRunner

View File

@@ -47,6 +47,7 @@ class TestClineIntegration(MarkdownIntegrationTests):
FOLDER = ".clinerules/"
COMMANDS_SUBDIR = "workflows"
REGISTRAR_DIR = ".clinerules/workflows"
CONTEXT_FILE = ".clinerules/specify-rules.md"
@pytest.mark.parametrize(
"cmd_name, expected_filename",
@@ -104,6 +105,7 @@ class TestClineIntegration(MarkdownIntegrationTests):
for f in created
if "scripts" not in f.parts
and f.suffix == ".md"
and f.name != i.context_file
]
for f in cmd_files:
assert f.exists()
@@ -203,4 +205,18 @@ class TestClineIntegration(MarkdownIntegrationTests):
files.append(".specify/workflows/speckit/workflow.yml")
files.append(".specify/workflows/workflow-registry.json")
# Bundled agent-context extension
files.append(".specify/extensions.yml")
files.append(".specify/extensions/.registry")
files.append(".specify/extensions/agent-context/README.md")
files.append(".specify/extensions/agent-context/agent-context-config.yml")
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
files.append(".specify/extensions/agent-context/extension.yml")
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
# Agent context file (if set)
if i.context_file:
files.append(i.context_file)
return sorted(files)

View File

@@ -1,7 +1,5 @@
"""Tests for CodebuddyIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
@@ -10,12 +8,4 @@ class TestCodebuddyIntegration(MarkdownIntegrationTests):
FOLDER = ".codebuddy/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".codebuddy/commands"
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"
)
CONTEXT_FILE = "CODEBUDDY.md"

View File

@@ -11,6 +11,7 @@ class TestCodexIntegration(SkillsIntegrationTests):
FOLDER = ".agents/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".agents/skills"
CONTEXT_FILE = "AGENTS.md"
class TestCodexInitFlow:
@@ -28,11 +29,23 @@ class TestCodexInitFlow:
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
"""The core plan skill must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
def test_plan_skill_references_configured_context_files(self, tmp_path):
"""Plan skill should render all configured agent context files."""
from specify_cli import _save_agent_context_config
target = tmp_path / "test-proj"
target.mkdir()
_save_agent_context_config(
target,
{
"context_file": "AGENTS.md",
"context_files": ["AGENTS.md", "CLAUDE.md"],
"context_markers": {
"start": "<!-- SPECKIT START -->",
"end": "<!-- SPECKIT END -->",
},
},
)
integration = get_integration("codex")
manifest = IntegrationManifest("codex", target)
@@ -40,32 +53,44 @@ class TestCodexInitFlow:
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
content = plan_skill.read_text(encoding="utf-8")
assert "AGENTS.md, CLAUDE.md" in content
assert "__CONTEXT_FILE__" not in content
def test_plan_skill_ignores_extension_config(self, tmp_path):
"""The extension config must not influence rendered commands: the CLI
no longer reads any context-file metadata when rendering."""
import yaml
def test_plan_skill_ignores_context_files_when_agent_context_disabled(
self, tmp_path
):
"""Disabled agent-context must not leak stale context_files into commands."""
from specify_cli import _save_agent_context_config
target = tmp_path / "test-proj"
target.mkdir()
ext_cfg = (
target
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
ext_cfg.parent.mkdir(parents=True, exist_ok=True)
ext_cfg.write_text(
yaml.safe_dump(
{
"context_file": "FROM_CONFIG.md",
"context_files": ["FROM_CONFIG.md", "ALSO_CONFIG.md"],
}
),
registry = target / ".specify" / "extensions" / ".registry"
registry.parent.mkdir(parents=True, exist_ok=True)
registry.write_text(
"""
{
"schema_version": "1.0",
"extensions": {
"agent-context": {
"version": "1.0.0",
"enabled": false
}
}
}
""".strip(),
encoding="utf-8",
)
_save_agent_context_config(
target,
{
"context_file": "AGENTS.md",
"context_files": ["../outside.md", "CLAUDE.md"],
"context_markers": {
"start": "<!-- SPECKIT START -->",
"end": "<!-- SPECKIT END -->",
},
},
)
integration = get_integration("codex")
manifest = IntegrationManifest("codex", target)
@@ -73,8 +98,9 @@ class TestCodexInitFlow:
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
content = plan_skill.read_text(encoding="utf-8")
assert "FROM_CONFIG.md" not in content
assert "ALSO_CONFIG.md" not in content
assert "AGENTS.md, CLAUDE.md" not in content
assert "../outside.md" not in content
assert "AGENTS.md" in content
assert "__CONTEXT_FILE__" not in content

View File

@@ -17,6 +17,7 @@ class TestCopilotIntegration:
assert copilot.config["folder"] == ".github/"
assert copilot.config["commands_subdir"] == "agents"
assert copilot.registrar_config["extension"] == ".agent.md"
assert copilot.context_file == ".github/copilot-instructions.md"
def test_command_filename_agent_md(self):
copilot = get_integration("copilot")
@@ -161,9 +162,8 @@ class TestCopilotIntegration:
assert "Copy `.specify/templates/spec-template.md`" not in content
assert "Load `.specify/templates/spec-template.md`" not in content
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The core plan command must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan command must reference copilot's context file."""
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
m = IntegrationManifest("copilot", tmp_path)
@@ -171,6 +171,9 @@ class TestCopilotIntegration:
plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert copilot.context_file in content, (
f"Plan command should reference {copilot.context_file!r}"
)
assert "__CONTEXT_FILE__" not in content
def test_complete_file_inventory_sh(self, tmp_path):
@@ -190,6 +193,7 @@ class TestCopilotIntegration:
assert result.exit_code == 0
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([
".github/agents/speckit.agent-context.update.agent.md",
".github/agents/speckit.analyze.agent.md",
".github/agents/speckit.checklist.agent.md",
".github/agents/speckit.clarify.agent.md",
@@ -200,6 +204,7 @@ class TestCopilotIntegration:
".github/agents/speckit.specify.agent.md",
".github/agents/speckit.tasks.agent.md",
".github/agents/speckit.taskstoissues.agent.md",
".github/prompts/speckit.agent-context.update.prompt.md",
".github/prompts/speckit.analyze.prompt.md",
".github/prompts/speckit.checklist.prompt.md",
".github/prompts/speckit.clarify.prompt.md",
@@ -211,6 +216,15 @@ class TestCopilotIntegration:
".github/prompts/speckit.tasks.prompt.md",
".github/prompts/speckit.taskstoissues.prompt.md",
".vscode/settings.json",
".github/copilot-instructions.md",
".specify/extensions.yml",
".specify/extensions/.registry",
".specify/extensions/agent-context/README.md",
".specify/extensions/agent-context/agent-context-config.yml",
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
".specify/extensions/agent-context/extension.yml",
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
".specify/integration.json",
".specify/init-options.json",
".specify/integrations/copilot.manifest.json",
@@ -251,6 +265,7 @@ class TestCopilotIntegration:
assert result.exit_code == 0
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([
".github/agents/speckit.agent-context.update.agent.md",
".github/agents/speckit.analyze.agent.md",
".github/agents/speckit.checklist.agent.md",
".github/agents/speckit.clarify.agent.md",
@@ -261,6 +276,7 @@ class TestCopilotIntegration:
".github/agents/speckit.specify.agent.md",
".github/agents/speckit.tasks.agent.md",
".github/agents/speckit.taskstoissues.agent.md",
".github/prompts/speckit.agent-context.update.prompt.md",
".github/prompts/speckit.analyze.prompt.md",
".github/prompts/speckit.checklist.prompt.md",
".github/prompts/speckit.clarify.prompt.md",
@@ -272,6 +288,15 @@ class TestCopilotIntegration:
".github/prompts/speckit.tasks.prompt.md",
".github/prompts/speckit.taskstoissues.prompt.md",
".vscode/settings.json",
".github/copilot-instructions.md",
".specify/extensions.yml",
".specify/extensions/.registry",
".specify/extensions/agent-context/README.md",
".specify/extensions/agent-context/agent-context-config.yml",
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
".specify/extensions/agent-context/extension.yml",
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
".specify/integration.json",
".specify/init-options.json",
".specify/integrations/copilot.manifest.json",
@@ -512,14 +537,14 @@ class TestCopilotSkillsMode:
body = parts[2].strip() if len(parts) >= 3 else ""
assert len(body) > 0, f"{f} has empty body"
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
"""The core plan skill must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan skill must reference copilot's context file."""
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert copilot.context_file in content
assert "__CONTEXT_FILE__" not in content
# -- Manifest tracking ------------------------------------------------
@@ -578,13 +603,14 @@ class TestCopilotSkillsMode:
# -- Context section ---------------------------------------------------
def test_skills_setup_does_not_write_context_section(self, tmp_path):
def test_skills_setup_upserts_context_section(self, tmp_path):
copilot = self._make_copilot()
self._setup_skills(copilot, tmp_path)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text
ctx_path = tmp_path / copilot.context_file
assert ctx_path.exists()
content = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in content
assert "<!-- SPECKIT END -->" in content
# -- CLI integration test ---------------------------------------------
@@ -633,8 +659,20 @@ class TestCopilotSkillsMode:
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
expected = sorted([
# Skill files (core commands)
# Skill files (core + extension-installed agent-context command)
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
".github/skills/speckit-agent-context-update/SKILL.md",
# Context file
".github/copilot-instructions.md",
# Bundled agent-context extension
".specify/extensions.yml",
".specify/extensions/.registry",
".specify/extensions/agent-context/README.md",
".specify/extensions/agent-context/agent-context-config.yml",
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
".specify/extensions/agent-context/extension.yml",
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
# Integration metadata
".specify/init-options.json",
".specify/integration.json",

View File

@@ -1,8 +1,10 @@
"""Tests for CursorAgentIntegration."""
from pathlib import Path
from urllib.parse import urlparse
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
@@ -12,6 +14,82 @@ class TestCursorAgentIntegration(SkillsIntegrationTests):
FOLDER = ".cursor/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".cursor/skills"
CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
class TestCursorMdcFrontmatter:
"""Verify .mdc frontmatter handling in upsert/remove context section."""
def _setup(self, tmp_path: Path):
i = get_integration("cursor-agent")
m = IntegrationManifest("cursor-agent", tmp_path)
return i, m
def test_new_mdc_gets_frontmatter(self, tmp_path):
"""A freshly created .mdc file includes alwaysApply: true."""
i, m = self._setup(tmp_path)
i.setup(tmp_path, m)
ctx = (tmp_path / i.context_file).read_text(encoding="utf-8")
assert ctx.startswith("---\n")
assert "alwaysApply: true" in ctx
def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path):
"""An existing .mdc without frontmatter gets it added."""
i, m = self._setup(tmp_path)
ctx_path = tmp_path / i.context_file
ctx_path.parent.mkdir(parents=True, exist_ok=True)
ctx_path.write_text("# User rules\n", encoding="utf-8")
i.upsert_context_section(tmp_path)
content = ctx_path.read_text(encoding="utf-8")
assert content.lstrip().startswith("---")
assert "alwaysApply: true" in content
assert "# User rules" in content
def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path):
"""An existing .mdc with custom frontmatter is preserved."""
i, m = self._setup(tmp_path)
ctx_path = tmp_path / i.context_file
ctx_path.parent.mkdir(parents=True, exist_ok=True)
ctx_path.write_text(
"---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n",
encoding="utf-8",
)
i.upsert_context_section(tmp_path)
content = ctx_path.read_text(encoding="utf-8")
assert "alwaysApply: true" in content
assert "customKey: hello" in content
assert "<!-- SPECKIT START -->" in content
def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path):
"""An .mdc with alwaysApply: false gets corrected."""
i, m = self._setup(tmp_path)
ctx_path = tmp_path / i.context_file
ctx_path.parent.mkdir(parents=True, exist_ok=True)
ctx_path.write_text(
"---\nalwaysApply: false\n---\n\n# Rules\n",
encoding="utf-8",
)
i.upsert_context_section(tmp_path)
content = ctx_path.read_text(encoding="utf-8")
assert "alwaysApply: true" in content
assert "alwaysApply: false" not in content
def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path):
"""Repeated upserts don't duplicate frontmatter."""
i, m = self._setup(tmp_path)
i.upsert_context_section(tmp_path)
i.upsert_context_section(tmp_path)
content = (tmp_path / i.context_file).read_text(encoding="utf-8")
assert content.count("alwaysApply") == 1
def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path):
"""Removing the section from a Speckit-only .mdc deletes the file."""
i, m = self._setup(tmp_path)
i.upsert_context_section(tmp_path)
ctx_path = tmp_path / i.context_file
assert ctx_path.exists()
i.remove_context_section(tmp_path)
assert not ctx_path.exists()
class TestCursorAgentInitFlow:

View File

@@ -8,6 +8,7 @@ class TestDevinIntegration(SkillsIntegrationTests):
FOLDER = ".devin/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".devin/skills"
CONTEXT_FILE = "AGENTS.md"
class TestDevinBuildExecArgs:

View File

@@ -11,6 +11,7 @@ class TestFirebenderIntegration(MarkdownIntegrationTests):
FOLDER = ".firebender/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".firebender/commands"
CONTEXT_FILE = ".firebender/rules/specify-rules.mdc"
# Firebender reads custom slash commands from ``.firebender/commands/*.mdc``,
# so this integration uses the ``.mdc`` extension instead of the ``.md``

View File

@@ -55,6 +55,7 @@ class TestForgeIntegration:
assert forge.config["requires_cli"] is True
assert forge.registrar_config["args"] == "{{parameters}}"
assert forge.registrar_config["extension"] == ".md"
assert forge.context_file == "AGENTS.md"
def test_command_filename_md(self):
forge = get_integration("forge")
@@ -72,15 +73,16 @@ class TestForgeIntegration:
for f in command_files:
assert f.name.endswith(".md")
def test_setup_does_not_write_context_section(self, tmp_path):
def test_setup_upserts_context_section(self, tmp_path):
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
forge.setup(tmp_path, m)
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text
ctx_path = tmp_path / forge.context_file
assert ctx_path.exists()
content = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in content
assert "<!-- SPECKIT END -->" in content
def test_all_created_files_tracked_in_manifest(self, tmp_path):
from specify_cli.integrations.forge import ForgeIntegration
@@ -162,9 +164,8 @@ class TestForgeIntegration:
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility"
)
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The core plan command must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan command must reference forge's context file."""
from specify_cli.integrations.forge import ForgeIntegration
forge = ForgeIntegration()
m = IntegrationManifest("forge", tmp_path)
@@ -172,6 +173,9 @@ class TestForgeIntegration:
plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert forge.context_file in content, (
f"Plan command should reference {forge.context_file!r}"
)
assert "__CONTEXT_FILE__" not in content
def test_forge_specific_transformations(self, tmp_path):

View File

@@ -8,3 +8,4 @@ class TestGeminiIntegration(TomlIntegrationTests):
FOLDER = ".gemini/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".gemini/commands"
CONTEXT_FILE = "GEMINI.md"

View File

@@ -31,6 +31,10 @@ class TestGenericIntegration:
i = get_integration("generic")
assert i.config["requires_cli"] is False
def test_context_file_is_agents_md(self):
i = get_integration("generic")
assert i.context_file == "AGENTS.md"
# -- Options ----------------------------------------------------------
def test_options_include_commands_dir(self):
@@ -157,24 +161,28 @@ class TestGenericIntegration:
# -- Context section ---------------------------------------------------
def test_setup_does_not_write_context_section(self, tmp_path):
def test_setup_upserts_context_section(self, tmp_path):
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
for path in tmp_path.rglob("*"):
if path.is_file():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "<!-- SPECKIT START -->" not in text
if i.context_file:
ctx_path = tmp_path / i.context_file
assert ctx_path.exists()
content = ctx_path.read_text(encoding="utf-8")
assert "<!-- SPECKIT START -->" in content
assert "<!-- SPECKIT END -->" in content
def test_plan_command_has_no_context_placeholder(self, tmp_path):
"""The core plan command must not carry a context-file placeholder —
agent context files are owned by the opt-in agent-context extension."""
def test_plan_references_correct_context_file(self, tmp_path):
"""The generated plan command must reference generic's context file."""
i = get_integration("generic")
m = IntegrationManifest("generic", tmp_path)
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md"
assert plan_file.exists()
content = plan_file.read_text(encoding="utf-8")
assert i.context_file in content, (
f"Plan command should reference {i.context_file!r}"
)
assert "__CONTEXT_FILE__" not in content
def test_plan_defines_quickstart_as_validation_guide(self, tmp_path):
@@ -248,6 +256,28 @@ class TestGenericIntegration:
# Generic requires --commands-dir via --integration-options
assert result.exit_code != 0
def test_init_options_includes_context_file(self, tmp_path):
"""agent-context extension config must include context_file for the generic integration."""
import yaml
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "opts-generic"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", "generic",
"--integration-options=--commands-dir .myagent/commands",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
assert ext_cfg.get("context_file") == "AGENTS.md"
def test_complete_file_inventory_sh(self, tmp_path):
"""Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script sh."""
@@ -272,6 +302,7 @@ class TestGenericIntegration:
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
)
expected = sorted([
"AGENTS.md",
".myagent/commands/speckit.analyze.md",
".myagent/commands/speckit.checklist.md",
".myagent/commands/speckit.clarify.md",
@@ -282,6 +313,14 @@ class TestGenericIntegration:
".myagent/commands/speckit.specify.md",
".myagent/commands/speckit.tasks.md",
".myagent/commands/speckit.taskstoissues.md",
".specify/extensions.yml",
".specify/extensions/.registry",
".specify/extensions/agent-context/README.md",
".specify/extensions/agent-context/agent-context-config.yml",
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
".specify/extensions/agent-context/extension.yml",
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/generic.manifest.json",
@@ -328,6 +367,7 @@ class TestGenericIntegration:
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
)
expected = sorted([
"AGENTS.md",
".myagent/commands/speckit.analyze.md",
".myagent/commands/speckit.checklist.md",
".myagent/commands/speckit.clarify.md",
@@ -338,6 +378,14 @@ class TestGenericIntegration:
".myagent/commands/speckit.specify.md",
".myagent/commands/speckit.tasks.md",
".myagent/commands/speckit.taskstoissues.md",
".specify/extensions.yml",
".specify/extensions/.registry",
".specify/extensions/agent-context/README.md",
".specify/extensions/agent-context/agent-context-config.yml",
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
".specify/extensions/agent-context/extension.yml",
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
".specify/init-options.json",
".specify/integration.json",
".specify/integrations/generic.manifest.json",

Some files were not shown because too many files have changed in this diff Show More