mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 22:32:13 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3194c543b | ||
|
|
9768b1eb88 | ||
|
|
c9c02ae790 | ||
|
|
d79a514b30 | ||
|
|
ee17b04784 | ||
|
|
a1b8de68bc | ||
|
|
7bab0568c5 | ||
|
|
7c558ab241 | ||
|
|
39921ddd3b | ||
|
|
d82eed859c | ||
|
|
442a581358 | ||
|
|
ed10b32014 | ||
|
|
14da893e4f | ||
|
|
39925ac084 | ||
|
|
866424385c | ||
|
|
44aac9f6e4 | ||
|
|
4230685e26 | ||
|
|
258dd8e380 | ||
|
|
122a794d83 |
28
.editorconfig
Normal file
28
.editorconfig
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{json,jsonc}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{sh,bash}]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{ps1,psm1,psd1}]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -2,6 +2,48 @@
|
|||||||
|
|
||||||
<!-- insert new changelog below this comment -->
|
<!-- insert new changelog below this comment -->
|
||||||
|
|
||||||
|
## [0.9.2] - 2026-06-02
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Update agent parity governance preset catalog entry (#2777)
|
||||||
|
- fix: resolve GitHub release asset API URL for private repo extension downloads (#2792)
|
||||||
|
- fix: remove unsupported mode: frontmatter from Copilot skills mode (fixes #2799) (#2819)
|
||||||
|
- refactor(integrations): co-locate integration commands in integrations/ domain dir (PR-5/8) (#2720)
|
||||||
|
- Update Product Forge extension to v1.6.0 (#2820)
|
||||||
|
- feat(workflows): add continue_on_error step field for non-halting failures (#2663)
|
||||||
|
- chore: add .editorconfig for consistent code formatting (#2366)
|
||||||
|
- fix(shared-infra): record skipped files in speckit.manifest.json (#2483)
|
||||||
|
- chore: release 0.9.1, begin 0.9.2.dev0 development (#2818)
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-06-02
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- fix(cli): pin UTF-8 encoding on init-options and .extensionignore I/O (#2686)
|
||||||
|
- docs: list Hermes in supported integrations table (#2768)
|
||||||
|
- fix(copilot): resolve active spec template (#2765)
|
||||||
|
- fix: add missing agent-context extension entries to Cline _expected_files (#2797)
|
||||||
|
- Add spec-kit-linear extension to community catalog (#2795)
|
||||||
|
- feat: add native Cline integration (#2508)
|
||||||
|
- Update workflow-preset community catalog entry (#2756)
|
||||||
|
- chore: release 0.9.0, begin 0.9.1.dev0 development (#2794)
|
||||||
|
- Add RAG Azure Builder extension to community catalog (#2793)
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-06-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- chore: recompile workflow lock files (#2774)
|
||||||
|
- Add Multi-Sites Spec Kit extension to community catalog (#2791)
|
||||||
|
- Update Product Spec Extension to v0.8.3 (#2790)
|
||||||
|
- Publish May 2026 Newsletter (#2787)
|
||||||
|
- fix: move URL install confirmation prompt before spinner (#2783) (#2784)
|
||||||
|
- Update Reqnroll BDD extension to v1.1.0 (#2775)
|
||||||
|
- Extract agent context updates into bundled agent-context extension (#2546)
|
||||||
|
- chore(deps): bump actions/setup-dotnet from 5.2.0 to 5.3.0 (#2755)
|
||||||
|
- chore: release 0.8.18, begin 0.8.19.dev0 development (#2766)
|
||||||
|
|
||||||
## [0.8.18] - 2026-05-29
|
## [0.8.18] - 2026-05-29
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
|||||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||||
|
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear](https://github.com/ashbrener/spec-kit-linear) |
|
||||||
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
||||||
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
|
| MAQA Azure DevOps Integration | Azure DevOps Boards integration for MAQA — syncs User Stories and Task children as features progress | `integration` | Read+Write | [spec-kit-maqa-azure-devops](https://github.com/GenieRobot/spec-kit-maqa-azure-devops) |
|
||||||
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
|
| MAQA CI/CD Gate | Auto-detects GitHub Actions, CircleCI, GitLab CI, and Bitbucket Pipelines. Blocks QA handoff until pipeline is green. | `process` | Read+Write | [spec-kit-maqa-ci](https://github.com/GenieRobot/spec-kit-maqa-ci) |
|
||||||
@@ -78,11 +79,12 @@ The following community-contributed extensions are available in [`catalog.commun
|
|||||||
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
||||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||||
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
|
||||||
| Product Forge | Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
| Product Forge | Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
|
||||||
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
|
| Product Spec Extension | Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs | `docs` | Read+Write | [spec-kit-product](https://github.com/d0whc3r/spec-kit-product) |
|
||||||
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
|
||||||
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
| Project Status | Show current SDD workflow progress — active feature, artifact status, task completion, workflow phase, and extensions summary | `visibility` | Read-only | [spec-kit-status](https://github.com/KhawarHabibKhan/spec-kit-status) |
|
||||||
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
| QA Testing Extension | Systematic QA testing with browser-driven or CLI-based validation of acceptance criteria from spec | `code` | Read-only | [spec-kit-qa](https://github.com/arunt14/spec-kit-qa) |
|
||||||
|
| RAG Azure Builder | Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows. | `process` | Read+Write | [spec-kit-extension-rag-azure-builder](https://github.com/Sertxito/spec-kit-extension-rag-azure-builder) |
|
||||||
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
|
| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss-Projects/spec-kit-ralph) |
|
||||||
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
| Reconcile Extension | Reconcile implementation drift by surgically updating feature artifacts. | `docs` | Read+Write | [spec-kit-reconcile](https://github.com/stn1slv/spec-kit-reconcile) |
|
||||||
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
| Red Team | Adversarial review of specs before /speckit.plan — parallel lens agents surface risks that clarify/analyze structurally can't (prompt injection, integrity gaps, cross-spec drift, silent failures). Produces a structured findings report; no auto-edits to specs. | `docs` | Read+Write | [spec-kit-red-team](https://github.com/ashbrener/spec-kit-red-team) |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
|||||||
| Preset | Purpose | Provides | Requires | URL |
|
| Preset | Purpose | Provides | Requires | URL |
|
||||||
|--------|---------|----------|----------|-----|
|
|--------|---------|----------|----------|-----|
|
||||||
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||||
| Agent Parity Governance | Keeps shared AI-agent instructions aligned across project-defined agent guidance surfaces and documents intentional deviations | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
| Agent Parity Governance | Keeps shared AI-agent instructions aligned and adds agent-neutral Spec Kit model-routing guidance across project-defined agent guidance surfaces | 9 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||||
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||||
@@ -27,6 +27,6 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
|||||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||||
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
||||||
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 23 templates, 7 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
|
| Workflow Preset | Behavior-first specification, design artifacts, and agent-native handoff orchestration — adds requirement-phase behavior drafts, formal BDD/UIF/behavior contracts, optional design artifacts, and scoped implementation handoffs with Core Agent, Vertical Planner Agent, and Worker Agent modes | 22 templates, 8 commands | — | [spec-kit-workflow-preset](https://github.com/bigsmartben/spec-kit-workflow-preset) |
|
||||||
|
|
||||||
To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md).
|
To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
|||||||
| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically |
|
| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically |
|
||||||
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
|
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
|
||||||
| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` |
|
| [Claude Code](https://www.anthropic.com/claude-code) | `claude` | Skills-based integration; installs skills in `.claude/skills` |
|
||||||
|
| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent |
|
||||||
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | |
|
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | |
|
||||||
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
|
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
|
||||||
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
|
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
|
||||||
@@ -18,6 +19,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
|||||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
|
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
|
||||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
|
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
|
||||||
| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
|
| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
|
||||||
|
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
|
||||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
||||||
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | |
|
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | |
|
||||||
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-06-01T00:00:00Z",
|
"updated_at": "2026-06-02T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"aide": {
|
"aide": {
|
||||||
@@ -1246,6 +1246,39 @@
|
|||||||
"created_at": "2026-03-17T00:00:00Z",
|
"created_at": "2026-03-17T00:00:00Z",
|
||||||
"updated_at": "2026-03-17T00:00:00Z"
|
"updated_at": "2026-03-17T00:00:00Z"
|
||||||
},
|
},
|
||||||
|
"linear": {
|
||||||
|
"name": "Linear Integration",
|
||||||
|
"id": "linear",
|
||||||
|
"description": "Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional).",
|
||||||
|
"author": "Ash Brener",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"download_url": "https://github.com/ashbrener/spec-kit-linear/archive/refs/tags/v0.2.0.zip",
|
||||||
|
"repository": "https://github.com/ashbrener/spec-kit-linear",
|
||||||
|
"homepage": "https://github.com/ashbrener/spec-kit-linear",
|
||||||
|
"documentation": "https://github.com/ashbrener/spec-kit-linear/blob/main/README.md",
|
||||||
|
"changelog": "https://github.com/ashbrener/spec-kit-linear/releases",
|
||||||
|
"license": "MIT",
|
||||||
|
"requires": {
|
||||||
|
"speckit_version": ">=0.1.0"
|
||||||
|
},
|
||||||
|
"provides": {
|
||||||
|
"commands": 5,
|
||||||
|
"hooks": 6
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"issue-tracking",
|
||||||
|
"linear",
|
||||||
|
"tasks-sync",
|
||||||
|
"lifecycle-mirror",
|
||||||
|
"memory",
|
||||||
|
"cross-repo"
|
||||||
|
],
|
||||||
|
"verified": false,
|
||||||
|
"downloads": 0,
|
||||||
|
"stars": 0,
|
||||||
|
"created_at": "2026-06-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-06-01T00:00:00Z"
|
||||||
|
},
|
||||||
"m365": {
|
"m365": {
|
||||||
"name": "Microsoft 365 Integration",
|
"name": "Microsoft 365 Integration",
|
||||||
"id": "m365",
|
"id": "m365",
|
||||||
@@ -2022,10 +2055,10 @@
|
|||||||
"product-forge": {
|
"product-forge": {
|
||||||
"name": "Product Forge",
|
"name": "Product Forge",
|
||||||
"id": "product-forge",
|
"id": "product-forge",
|
||||||
"description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model",
|
"description": "Full product lifecycle from research to release — express/lite/standard/v-model tracks, living spec + traceability, structured journeys → E2E, monorepo, and selectable doc-structure strategies",
|
||||||
"author": "VaiYav",
|
"author": "VaiYav",
|
||||||
"version": "1.5.1",
|
"version": "1.6.0",
|
||||||
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.5.1.zip",
|
"download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.6.0.zip",
|
||||||
"repository": "https://github.com/VaiYav/speckit-product-forge",
|
"repository": "https://github.com/VaiYav/speckit-product-forge",
|
||||||
"homepage": "https://github.com/VaiYav/speckit-product-forge",
|
"homepage": "https://github.com/VaiYav/speckit-product-forge",
|
||||||
"documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md",
|
"documentation": "https://github.com/VaiYav/speckit-product-forge/blob/main/README.md",
|
||||||
@@ -2035,7 +2068,7 @@
|
|||||||
"speckit_version": ">=0.1.0"
|
"speckit_version": ">=0.1.0"
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"commands": 29,
|
"commands": 31,
|
||||||
"hooks": 0
|
"hooks": 0
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2049,7 +2082,7 @@
|
|||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-03-28T00:00:00Z",
|
"created_at": "2026-03-28T00:00:00Z",
|
||||||
"updated_at": "2026-04-24T15:52:00Z"
|
"updated_at": "2026-06-02T00:00:00Z"
|
||||||
},
|
},
|
||||||
"qa": {
|
"qa": {
|
||||||
"name": "QA Testing Extension",
|
"name": "QA Testing Extension",
|
||||||
@@ -2081,6 +2114,38 @@
|
|||||||
"created_at": "2026-04-01T00:00:00Z",
|
"created_at": "2026-04-01T00:00:00Z",
|
||||||
"updated_at": "2026-04-01T00:00:00Z"
|
"updated_at": "2026-04-01T00:00:00Z"
|
||||||
},
|
},
|
||||||
|
"rag-azure-builder": {
|
||||||
|
"name": "RAG Azure Builder",
|
||||||
|
"id": "rag-azure-builder",
|
||||||
|
"description": "Spec Kit extension for onboarding and operating an Azure RAG stack with guided workflows.",
|
||||||
|
"author": "Sertxito",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"download_url": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder/archive/refs/tags/v1.2.0.zip",
|
||||||
|
"repository": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder",
|
||||||
|
"homepage": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder",
|
||||||
|
"documentation": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder#readme",
|
||||||
|
"changelog": "https://github.com/Sertxito/spec-kit-extension-rag-azure-builder/blob/main/CHANGELOG.md",
|
||||||
|
"license": "MIT",
|
||||||
|
"requires": {
|
||||||
|
"speckit_version": ">=0.8.0"
|
||||||
|
},
|
||||||
|
"provides": {
|
||||||
|
"commands": 5,
|
||||||
|
"hooks": 0
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"azure",
|
||||||
|
"rag",
|
||||||
|
"search",
|
||||||
|
"onboarding",
|
||||||
|
"cost-optimization"
|
||||||
|
],
|
||||||
|
"verified": false,
|
||||||
|
"downloads": 0,
|
||||||
|
"stars": 0,
|
||||||
|
"created_at": "2026-06-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-06-01T00:00:00Z"
|
||||||
|
},
|
||||||
"ralph": {
|
"ralph": {
|
||||||
"name": "Ralph Loop",
|
"name": "Ralph Loop",
|
||||||
"id": "ralph",
|
"id": "ralph",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-04-29T00:00:00Z",
|
"updated_at": "2026-05-13T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"claude": {
|
"claude": {
|
||||||
@@ -12,6 +12,15 @@
|
|||||||
"repository": "https://github.com/github/spec-kit",
|
"repository": "https://github.com/github/spec-kit",
|
||||||
"tags": ["cli", "anthropic"]
|
"tags": ["cli", "anthropic"]
|
||||||
},
|
},
|
||||||
|
"cline": {
|
||||||
|
"id": "cline",
|
||||||
|
"name": "Cline",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Cline IDE integration",
|
||||||
|
"author": "spec-kit-core",
|
||||||
|
"repository": "https://github.com/github/spec-kit",
|
||||||
|
"tags": ["ide"]
|
||||||
|
},
|
||||||
"copilot": {
|
"copilot": {
|
||||||
"id": "copilot",
|
"id": "copilot",
|
||||||
"name": "GitHub Copilot",
|
"name": "GitHub Copilot",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-05-27T00:00:00Z",
|
"updated_at": "2026-05-31T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||||
"presets": {
|
"presets": {
|
||||||
"a11y-governance": {
|
"a11y-governance": {
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
"agent-parity-governance": {
|
"agent-parity-governance": {
|
||||||
"name": "Agent Parity Governance",
|
"name": "Agent Parity Governance",
|
||||||
"id": "agent-parity-governance",
|
"id": "agent-parity-governance",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Keeps shared AI-agent guidance aligned across a project-defined set of agent instruction surfaces.",
|
"description": "Keeps shared AI-agent guidance aligned and adds agent-neutral Spec Kit model-routing guidance across declared agent instruction surfaces.",
|
||||||
"author": "Thorsten Hindermann",
|
"author": "Thorsten Hindermann",
|
||||||
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.1.0.zip",
|
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.2.0.zip",
|
||||||
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
|
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -46,18 +46,20 @@
|
|||||||
"speckit_version": ">=0.8.0"
|
"speckit_version": ">=0.8.0"
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"templates": 6,
|
"templates": 9,
|
||||||
"commands": 3
|
"commands": 3
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
"agents",
|
"agents",
|
||||||
"governance",
|
"governance",
|
||||||
"parity",
|
"parity",
|
||||||
|
"agent-md",
|
||||||
"agent-guidance",
|
"agent-guidance",
|
||||||
|
"model-routing",
|
||||||
"multi-agent"
|
"multi-agent"
|
||||||
],
|
],
|
||||||
"created_at": "2026-04-27T00:00:00Z",
|
"created_at": "2026-04-27T00:00:00Z",
|
||||||
"updated_at": "2026-04-27T00:00:00Z"
|
"updated_at": "2026-05-31T00:00:00Z"
|
||||||
},
|
},
|
||||||
"aide-in-place": {
|
"aide-in-place": {
|
||||||
"name": "AIDE In-Place Migration",
|
"name": "AIDE In-Place Migration",
|
||||||
@@ -593,11 +595,11 @@
|
|||||||
"workflow-preset": {
|
"workflow-preset": {
|
||||||
"name": "Workflow Preset",
|
"name": "Workflow Preset",
|
||||||
"id": "workflow-preset",
|
"id": "workflow-preset",
|
||||||
"version": "1.2.0",
|
"version": "1.3.1",
|
||||||
"description": "Behavior-first specification, design artifacts, and agent-native handoff orchestration.",
|
"description": "Behavior-first specification, design artifacts, and agent-native handoff orchestration.",
|
||||||
"author": "bigsmartben",
|
"author": "bigsmartben",
|
||||||
"repository": "https://github.com/bigsmartben/spec-kit-workflow-preset",
|
"repository": "https://github.com/bigsmartben/spec-kit-workflow-preset",
|
||||||
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/archive/refs/tags/v1.2.0.zip",
|
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.1/spec-kit-workflow-preset-v1.3.1.zip",
|
||||||
"homepage": "https://github.com/bigsmartben/spec-kit-workflow-preset",
|
"homepage": "https://github.com/bigsmartben/spec-kit-workflow-preset",
|
||||||
"documentation": "https://github.com/bigsmartben/spec-kit-workflow-preset/blob/main/README.md",
|
"documentation": "https://github.com/bigsmartben/spec-kit-workflow-preset/blob/main/README.md",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -605,8 +607,8 @@
|
|||||||
"speckit_version": ">=0.8.10.dev0"
|
"speckit_version": ">=0.8.10.dev0"
|
||||||
},
|
},
|
||||||
"provides": {
|
"provides": {
|
||||||
"templates": 23,
|
"templates": 22,
|
||||||
"commands": 7
|
"commands": 8
|
||||||
},
|
},
|
||||||
"tags": [
|
"tags": [
|
||||||
"behavior",
|
"behavior",
|
||||||
@@ -616,7 +618,7 @@
|
|||||||
"handoff"
|
"handoff"
|
||||||
],
|
],
|
||||||
"created_at": "2026-05-27T00:00:00Z",
|
"created_at": "2026-05-27T00:00:00Z",
|
||||||
"updated_at": "2026-05-27T00:00:00Z"
|
"updated_at": "2026-05-28T00:00:00Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.8.19.dev0"
|
version = "0.9.2"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,33 @@ class CommandRegistrar:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass # Circular import during module init; retry on next access
|
pass # Circular import during module init; retry on next access
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hyphenate_frontmatter_refs(val: Any) -> Any:
|
||||||
|
"""Recursively find any dotted references starting with speckit. and hyphenate them."""
|
||||||
|
if isinstance(val, dict):
|
||||||
|
return {
|
||||||
|
k: CommandRegistrar._hyphenate_frontmatter_refs(v)
|
||||||
|
for k, v in val.items()
|
||||||
|
}
|
||||||
|
elif isinstance(val, list):
|
||||||
|
return [CommandRegistrar._hyphenate_frontmatter_refs(x) for x in val]
|
||||||
|
elif isinstance(val, str):
|
||||||
|
return re.sub(
|
||||||
|
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
|
||||||
|
lambda m: m.group(0).replace(".", "-"),
|
||||||
|
val,
|
||||||
|
)
|
||||||
|
return val
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hyphenate_body_refs(body: str) -> str:
|
||||||
|
"""Hyphenate dotted speckit references in command body text."""
|
||||||
|
return re.sub(
|
||||||
|
r"\bspeckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*\b",
|
||||||
|
lambda m: m.group(0).replace(".", "-"),
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
||||||
"""Parse YAML frontmatter from Markdown content.
|
"""Parse YAML frontmatter from Markdown content.
|
||||||
@@ -408,6 +435,9 @@ class CommandRegistrar:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Compute the on-disk command or skill name for an agent."""
|
"""Compute the on-disk command or skill name for an agent."""
|
||||||
if agent_config["extension"] != "/SKILL.md":
|
if agent_config["extension"] != "/SKILL.md":
|
||||||
|
format_name = agent_config.get("format_name")
|
||||||
|
if format_name:
|
||||||
|
return format_name(cmd_name)
|
||||||
return cmd_name
|
return cmd_name
|
||||||
|
|
||||||
short_name = cmd_name
|
short_name = cmd_name
|
||||||
@@ -437,6 +467,13 @@ class CommandRegistrar:
|
|||||||
if not normalized.is_relative_to(base_normalized):
|
if not normalized.is_relative_to(base_normalized):
|
||||||
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")
|
raise ValueError(f"Output path {candidate!r} escapes directory {base!r}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_safe_command_name(name: str) -> bool:
|
||||||
|
"""Reject names that could escape the commands directory via path traversal."""
|
||||||
|
if os.path.sep in name or "/" in name or "\\" in name:
|
||||||
|
return False
|
||||||
|
return os.path.normpath(name) == name
|
||||||
|
|
||||||
def register_commands(
|
def register_commands(
|
||||||
self,
|
self,
|
||||||
agent_name: str,
|
agent_name: str,
|
||||||
@@ -482,9 +519,11 @@ class CommandRegistrar:
|
|||||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
registered = []
|
registered = []
|
||||||
|
is_cline_ext = agent_name == "cline" and source_id != "core"
|
||||||
|
|
||||||
for cmd_info in commands:
|
for cmd_info in commands:
|
||||||
cmd_name = cmd_info["name"]
|
cmd_name = cmd_info["name"]
|
||||||
|
aliases = cmd_info.get("aliases", [])
|
||||||
cmd_file = cmd_info["file"]
|
cmd_file = cmd_info["file"]
|
||||||
|
|
||||||
source_file = source_dir / cmd_file
|
source_file = source_dir / cmd_file
|
||||||
@@ -516,6 +555,10 @@ class CommandRegistrar:
|
|||||||
format_name = agent_config.get("format_name")
|
format_name = agent_config.get("format_name")
|
||||||
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name
|
frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name
|
||||||
|
|
||||||
|
if is_cline_ext:
|
||||||
|
frontmatter = self._hyphenate_frontmatter_refs(frontmatter)
|
||||||
|
body = self._hyphenate_body_refs(body)
|
||||||
|
|
||||||
body = self._convert_argument_placeholder(
|
body = self._convert_argument_placeholder(
|
||||||
body, "$ARGUMENTS", agent_config["args"]
|
body, "$ARGUMENTS", agent_config["args"]
|
||||||
)
|
)
|
||||||
@@ -585,7 +628,7 @@ class CommandRegistrar:
|
|||||||
|
|
||||||
registered.append(cmd_name)
|
registered.append(cmd_name)
|
||||||
|
|
||||||
for alias in cmd_info.get("aliases", []):
|
for alias in aliases:
|
||||||
alias_output_name = self._compute_output_name(
|
alias_output_name = self._compute_output_name(
|
||||||
agent_name, alias, agent_config
|
agent_name, alias, agent_config
|
||||||
)
|
)
|
||||||
@@ -909,22 +952,32 @@ class CommandRegistrar:
|
|||||||
output_name = self._compute_output_name(
|
output_name = self._compute_output_name(
|
||||||
agent_name, cmd_name, agent_config
|
agent_name, cmd_name, agent_config
|
||||||
)
|
)
|
||||||
|
|
||||||
|
names_to_clean = [output_name]
|
||||||
|
if output_name != cmd_name and self._is_safe_command_name(cmd_name):
|
||||||
|
names_to_clean.append(cmd_name)
|
||||||
|
|
||||||
for target_dir in dirs_to_clean:
|
for target_dir in dirs_to_clean:
|
||||||
cmd_file = (
|
for name in names_to_clean:
|
||||||
target_dir / f"{output_name}{agent_config['extension']}"
|
cmd_file = (
|
||||||
)
|
target_dir / f"{name}{agent_config['extension']}"
|
||||||
if cmd_file.exists() or cmd_file.is_symlink():
|
)
|
||||||
cmd_file.unlink()
|
try:
|
||||||
# For SKILL.md agents each command lives in its own
|
self._ensure_inside(cmd_file, target_dir)
|
||||||
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
|
except ValueError:
|
||||||
# SKILL.md). Remove the parent dir when it becomes
|
continue
|
||||||
# empty to avoid orphaned directories.
|
if cmd_file.exists() or cmd_file.is_symlink():
|
||||||
parent = cmd_file.parent
|
cmd_file.unlink()
|
||||||
if parent != target_dir and parent.exists():
|
# For SKILL.md agents each command lives in its own
|
||||||
try:
|
# subdirectory (e.g. .agents/skills/speckit-ext-cmd/
|
||||||
parent.rmdir()
|
# SKILL.md). Remove the parent dir when it becomes
|
||||||
except OSError:
|
# empty to avoid orphaned directories.
|
||||||
pass
|
parent = cmd_file.parent
|
||||||
|
if parent != target_dir and parent.exists():
|
||||||
|
try:
|
||||||
|
parent.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
if agent_name == "copilot":
|
if agent_name == "copilot":
|
||||||
prompt_file = (
|
prompt_file = (
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
"""specify extension * commands — placeholder for future extraction."""
|
|
||||||
from __future__ import annotations
|
|
||||||
@@ -151,13 +151,15 @@ def register(app: typer.Typer) -> None:
|
|||||||
# Lazy imports to avoid circular dependency — __init__.py imports this module
|
# Lazy imports to avoid circular dependency — __init__.py imports this module
|
||||||
from .. import (
|
from .. import (
|
||||||
_install_shared_infra_or_exit,
|
_install_shared_infra_or_exit,
|
||||||
_parse_integration_options,
|
|
||||||
_print_cli_warning,
|
_print_cli_warning,
|
||||||
_update_agent_context_config_file,
|
_update_agent_context_config_file,
|
||||||
_write_integration_json,
|
|
||||||
ensure_executable_scripts,
|
ensure_executable_scripts,
|
||||||
save_init_options,
|
save_init_options,
|
||||||
)
|
)
|
||||||
|
from ..integrations._commands import (
|
||||||
|
_parse_integration_options,
|
||||||
|
_write_integration_json,
|
||||||
|
)
|
||||||
from ..integration_runtime import with_integration_setting as _with_integration_setting
|
from ..integration_runtime import with_integration_setting as _with_integration_setting
|
||||||
|
|
||||||
show_banner()
|
show_banner()
|
||||||
@@ -726,6 +728,7 @@ def register(app: typer.Typer) -> None:
|
|||||||
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
|
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
|
||||||
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
|
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
|
||||||
devin_skill_mode = selected_ai == "devin"
|
devin_skill_mode = selected_ai == "devin"
|
||||||
|
cline_skill_mode = selected_ai == "cline"
|
||||||
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
|
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
|
||||||
|
|
||||||
if codex_skill_mode and not ai_skills:
|
if codex_skill_mode and not ai_skills:
|
||||||
@@ -749,7 +752,7 @@ def register(app: typer.Typer) -> None:
|
|||||||
return f"/speckit-{name}"
|
return f"/speckit-{name}"
|
||||||
if kimi_skill_mode:
|
if kimi_skill_mode:
|
||||||
return f"/skill:speckit-{name}"
|
return f"/skill:speckit-{name}"
|
||||||
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
|
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or cline_skill_mode:
|
||||||
return f"/speckit-{name}"
|
return f"/speckit-{name}"
|
||||||
return f"/speckit.{name}"
|
return f"/speckit.{name}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
"""specify integration * commands — placeholder for future extraction."""
|
|
||||||
from __future__ import annotations
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""specify preset * commands — placeholder for future extraction."""
|
|
||||||
from __future__ import annotations
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""specify workflow * commands — placeholder for future extraction."""
|
|
||||||
from __future__ import annotations
|
|
||||||
@@ -761,7 +761,28 @@ class ExtensionManager:
|
|||||||
if not ignore_file.exists():
|
if not ignore_file.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
lines: List[str] = ignore_file.read_text().splitlines()
|
# Pin UTF-8 explicitly: ``Path.read_text`` defaults to the system
|
||||||
|
# locale codec on Windows (cp1252 / gb2312 / cp932), which silently
|
||||||
|
# corrupts multibyte patterns when the file is shared across
|
||||||
|
# machines with different locales. The next line already
|
||||||
|
# normalises backslashes "so Windows-authored files work" — the
|
||||||
|
# codebase already expects Windows authors to write this file.
|
||||||
|
#
|
||||||
|
# A file that is not valid UTF-8 is a user-authoring mistake, so
|
||||||
|
# surface it as ``ValidationError`` with a pointer to the offending
|
||||||
|
# byte — the same pattern ``ExtensionManifest._load_yaml`` uses
|
||||||
|
# for ``extension.yml`` (see ``UnicodeDecodeError`` handler in
|
||||||
|
# this module). Without the wrap, the raw ``UnicodeDecodeError``
|
||||||
|
# would abort installation with a Python traceback instead of a
|
||||||
|
# clear message naming the file.
|
||||||
|
try:
|
||||||
|
raw = ignore_file.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
raise ValidationError(
|
||||||
|
f".extensionignore is not valid UTF-8: {ignore_file} "
|
||||||
|
f"({e.reason} at byte {e.start})"
|
||||||
|
)
|
||||||
|
lines: List[str] = raw.splitlines()
|
||||||
|
|
||||||
# Normalise backslashes in patterns so Windows-authored files work
|
# Normalise backslashes in patterns so Windows-authored files work
|
||||||
normalised: List[str] = []
|
normalised: List[str] = []
|
||||||
@@ -1728,13 +1749,59 @@ class ExtensionCatalog(CatalogStackBase):
|
|||||||
from specify_cli.authentication.http import build_request
|
from specify_cli.authentication.http import build_request
|
||||||
return build_request(url)
|
return build_request(url)
|
||||||
|
|
||||||
def _open_url(self, url: str, timeout: int = 10):
|
def _open_url(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
timeout: int = 10,
|
||||||
|
extra_headers: Optional[Dict[str, str]] = None,
|
||||||
|
):
|
||||||
"""Open a URL with provider-based auth, trying each configured provider.
|
"""Open a URL with provider-based auth, trying each configured provider.
|
||||||
|
|
||||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
||||||
"""
|
"""
|
||||||
from specify_cli.authentication.http import open_url
|
from specify_cli.authentication.http import open_url
|
||||||
return open_url(url, timeout)
|
return open_url(url, timeout, extra_headers=extra_headers)
|
||||||
|
|
||||||
|
def _resolve_github_release_asset_api_url(
|
||||||
|
self,
|
||||||
|
download_url: str,
|
||||||
|
timeout: int = 60,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Resolve a GitHub release asset URL to its API asset URL."""
|
||||||
|
import urllib.error
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(download_url)
|
||||||
|
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
|
||||||
|
if (
|
||||||
|
parsed.hostname == "api.github.com"
|
||||||
|
and len(parts) >= 6
|
||||||
|
and parts[:1] == ["repos"]
|
||||||
|
and parts[3:5] == ["releases", "assets"]
|
||||||
|
):
|
||||||
|
return download_url
|
||||||
|
|
||||||
|
if parsed.hostname != "github.com":
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(parts) < 6 or parts[2:4] != ["releases", "download"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
owner, repo, tag = parts[0], parts[1], parts[4]
|
||||||
|
asset_name = "/".join(parts[5:])
|
||||||
|
release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._open_url(release_url, timeout=timeout) as response:
|
||||||
|
release_data = json.loads(response.read())
|
||||||
|
except (urllib.error.URLError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for asset in release_data.get("assets", []):
|
||||||
|
if asset.get("name") == asset_name and asset.get("url"):
|
||||||
|
return str(asset["url"])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def get_active_catalogs(self) -> List[CatalogEntry]:
|
def get_active_catalogs(self) -> List[CatalogEntry]:
|
||||||
"""Get the ordered list of active catalogs.
|
"""Get the ordered list of active catalogs.
|
||||||
@@ -2134,9 +2201,15 @@ class ExtensionCatalog(CatalogStackBase):
|
|||||||
zip_filename = f"{extension_id}-{version}.zip"
|
zip_filename = f"{extension_id}-{version}.zip"
|
||||||
zip_path = target_dir / zip_filename
|
zip_path = target_dir / zip_filename
|
||||||
|
|
||||||
|
extra_headers = None
|
||||||
|
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
|
||||||
|
if resolved_download_url:
|
||||||
|
download_url = resolved_download_url
|
||||||
|
extra_headers = {"Accept": "application/octet-stream"}
|
||||||
|
|
||||||
# Download the ZIP file
|
# Download the ZIP file
|
||||||
try:
|
try:
|
||||||
with self._open_url(download_url, timeout=60) as response:
|
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
|
||||||
zip_data = response.read()
|
zip_data = response.read()
|
||||||
|
|
||||||
zip_path.write_bytes(zip_data)
|
zip_path.write_bytes(zip_data)
|
||||||
@@ -2413,6 +2486,7 @@ class HookExecutor:
|
|||||||
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
|
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
|
||||||
kimi_skill_mode = selected_ai == "kimi"
|
kimi_skill_mode = selected_ai == "kimi"
|
||||||
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
|
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
|
||||||
|
cline_mode = selected_ai == "cline"
|
||||||
|
|
||||||
skill_name = self._skill_name_from_command(command_id)
|
skill_name = self._skill_name_from_command(command_id)
|
||||||
if codex_skill_mode and skill_name:
|
if codex_skill_mode and skill_name:
|
||||||
@@ -2423,6 +2497,10 @@ class HookExecutor:
|
|||||||
return f"/skill:{skill_name}"
|
return f"/skill:{skill_name}"
|
||||||
if cursor_skill_mode and skill_name:
|
if cursor_skill_mode and skill_name:
|
||||||
return f"/{skill_name}"
|
return f"/{skill_name}"
|
||||||
|
if cline_mode:
|
||||||
|
from .integrations.cline import format_cline_command_name
|
||||||
|
|
||||||
|
return f"/{format_cline_command_name(command_id)}"
|
||||||
|
|
||||||
return f"/{command_id}"
|
return f"/{command_id}"
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ def _register_builtins() -> None:
|
|||||||
from .auggie import AuggieIntegration
|
from .auggie import AuggieIntegration
|
||||||
from .bob import BobIntegration
|
from .bob import BobIntegration
|
||||||
from .claude import ClaudeIntegration
|
from .claude import ClaudeIntegration
|
||||||
|
from .cline import ClineIntegration
|
||||||
from .codebuddy import CodebuddyIntegration
|
from .codebuddy import CodebuddyIntegration
|
||||||
from .codex import CodexIntegration
|
from .codex import CodexIntegration
|
||||||
from .copilot import CopilotIntegration
|
from .copilot import CopilotIntegration
|
||||||
@@ -85,6 +86,7 @@ def _register_builtins() -> None:
|
|||||||
_register(AuggieIntegration())
|
_register(AuggieIntegration())
|
||||||
_register(BobIntegration())
|
_register(BobIntegration())
|
||||||
_register(ClaudeIntegration())
|
_register(ClaudeIntegration())
|
||||||
|
_register(ClineIntegration())
|
||||||
_register(CodebuddyIntegration())
|
_register(CodebuddyIntegration())
|
||||||
_register(CodexIntegration())
|
_register(CodexIntegration())
|
||||||
_register(CopilotIntegration())
|
_register(CopilotIntegration())
|
||||||
|
|||||||
34
src/specify_cli/integrations/_commands.py
Normal file
34
src/specify_cli/integrations/_commands.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""specify integration * commands — app objects and register() entry point."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .._assets import get_speckit_version # noqa: F401 — re-exported for monkeypatching in tests
|
||||||
|
|
||||||
|
# Re-export helpers used by commands/init.py and tests
|
||||||
|
from ._helpers import ( # noqa: F401
|
||||||
|
_cli_error_detail,
|
||||||
|
_cli_phase_label,
|
||||||
|
_parse_integration_options,
|
||||||
|
_write_integration_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
integration_app = typer.Typer(
|
||||||
|
name="integration",
|
||||||
|
help="Manage coding agent integrations",
|
||||||
|
add_completion=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
integration_catalog_app = typer.Typer(
|
||||||
|
name="catalog",
|
||||||
|
help="Manage integration catalog sources",
|
||||||
|
add_completion=False,
|
||||||
|
)
|
||||||
|
integration_app.add_typer(integration_catalog_app, name="catalog")
|
||||||
|
|
||||||
|
|
||||||
|
def register(app: typer.Typer) -> None:
|
||||||
|
from . import _install_commands # noqa: F401 — registers handlers via decorators
|
||||||
|
from . import _migrate_commands # noqa: F401
|
||||||
|
from . import _query_commands # noqa: F401
|
||||||
|
app.add_typer(integration_app, name="integration")
|
||||||
402
src/specify_cli/integrations/_helpers.py
Normal file
402
src/specify_cli/integrations/_helpers.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
"""specify integration helpers — internal utilities shared across command modules."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .._agent_config import SCRIPT_TYPE_CHOICES
|
||||||
|
from .._console import console
|
||||||
|
from ..integration_runtime import (
|
||||||
|
invoke_separator_for_integration as _invoke_separator_for_integration,
|
||||||
|
resolve_integration_options as _resolve_integration_options_impl,
|
||||||
|
with_integration_setting as _with_integration_setting,
|
||||||
|
)
|
||||||
|
from ..integration_state import (
|
||||||
|
INTEGRATION_JSON,
|
||||||
|
INTEGRATION_STATE_SCHEMA,
|
||||||
|
integration_setting as _integration_setting,
|
||||||
|
try_read_integration_json as _try_read_integration_json,
|
||||||
|
write_integration_json as _write_integration_json_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_speckit_version() -> str:
|
||||||
|
"""Return the current Spec Kit version.
|
||||||
|
|
||||||
|
Resolved lazily through ``_commands.get_speckit_version`` so that tests
|
||||||
|
that monkeypatch ``specify_cli.integrations._commands.get_speckit_version``
|
||||||
|
still affect helpers called from the command handlers.
|
||||||
|
"""
|
||||||
|
from . import _commands # noqa: PLC0415 — intentional late import to avoid circular + enable patching
|
||||||
|
return _commands.get_speckit_version()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON read / write helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _read_integration_json(project_root: Path) -> dict[str, Any]:
|
||||||
|
"""Load ``.specify/integration.json``. Returns normalized state when present.
|
||||||
|
|
||||||
|
Delegates the parse / schema-guard logic to the shared
|
||||||
|
:func:`_try_read_integration_json` helper so the CLI and workflow engine
|
||||||
|
cannot drift on validation rules. Each error variant is translated into
|
||||||
|
the existing loud-fail UX (console message + ``typer.Exit(1)``).
|
||||||
|
"""
|
||||||
|
path = project_root / INTEGRATION_JSON
|
||||||
|
state, error = _try_read_integration_json(project_root)
|
||||||
|
if error is None:
|
||||||
|
return state or {}
|
||||||
|
if error.kind == "decode":
|
||||||
|
console.print(f"[red]Error:[/red] {path} contains invalid JSON or is not valid UTF-8.")
|
||||||
|
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
|
||||||
|
console.print(f"[dim]Details:[/dim] {error.detail}")
|
||||||
|
elif error.kind == "os":
|
||||||
|
console.print(f"[red]Error:[/red] Could not read {path}.")
|
||||||
|
console.print(f"Please fix file permissions or delete {INTEGRATION_JSON} and retry.")
|
||||||
|
console.print(f"[dim]Details:[/dim] {error.detail}")
|
||||||
|
elif error.kind == "not_object":
|
||||||
|
console.print(
|
||||||
|
f"[red]Error:[/red] {path} must contain a JSON object, got {error.detail}."
|
||||||
|
)
|
||||||
|
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
|
||||||
|
elif error.kind == "schema_too_new":
|
||||||
|
console.print(
|
||||||
|
f"[red]Error:[/red] {path} uses integration state schema {error.schema}, "
|
||||||
|
f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}."
|
||||||
|
)
|
||||||
|
console.print("Please upgrade Spec Kit before modifying integrations.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_integration_json(
|
||||||
|
project_root: Path,
|
||||||
|
integration_key: str | None,
|
||||||
|
installed_integrations: list[str] | None = None,
|
||||||
|
integration_settings: dict[str, dict[str, Any]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write ``.specify/integration.json`` with legacy-compatible state."""
|
||||||
|
_write_integration_json_file(
|
||||||
|
project_root,
|
||||||
|
version=_get_speckit_version(),
|
||||||
|
integration_key=integration_key,
|
||||||
|
installed_integrations=installed_integrations,
|
||||||
|
settings=integration_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# init-options.json helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_init_options_speckit_version(project_root: Path) -> None:
|
||||||
|
"""Refresh only the Spec Kit version recorded in init-options.json."""
|
||||||
|
from .. import load_init_options, save_init_options
|
||||||
|
opts = load_init_options(project_root)
|
||||||
|
if not isinstance(opts, dict) or not opts:
|
||||||
|
return
|
||||||
|
opts["speckit_version"] = _get_speckit_version()
|
||||||
|
save_init_options(project_root, opts)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None:
|
||||||
|
"""Clear active integration keys from init-options.json when they match.
|
||||||
|
|
||||||
|
Also clears ``context_file`` from the agent-context extension config so
|
||||||
|
no stale path is left behind when the integration is uninstalled.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
elif has_legacy_context_keys:
|
||||||
|
save_init_options(project_root, opts)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_integration_json(project_root: Path) -> None:
|
||||||
|
"""Remove ``.specify/integration.json`` if it exists."""
|
||||||
|
path = project_root / INTEGRATION_JSON
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error sentinels
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError)
|
||||||
|
|
||||||
|
|
||||||
|
class _SharedTemplateRefreshError(RuntimeError):
|
||||||
|
"""Raised when default integration metadata should not be persisted."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Script type resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _normalize_script_type(script_type: str, source: str) -> str:
|
||||||
|
"""Normalize and validate a script type from CLI/config sources."""
|
||||||
|
normalized = script_type.strip().lower()
|
||||||
|
if normalized in SCRIPT_TYPE_CHOICES:
|
||||||
|
return normalized
|
||||||
|
console.print(
|
||||||
|
f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. "
|
||||||
|
f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}."
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_script_type(project_root: Path, script_type: str | None) -> str:
|
||||||
|
"""Resolve the script type from the CLI flag or init-options.json."""
|
||||||
|
from .. import load_init_options
|
||||||
|
if script_type:
|
||||||
|
return _normalize_script_type(script_type, "--script")
|
||||||
|
opts = load_init_options(project_root)
|
||||||
|
saved = opts.get("script")
|
||||||
|
if isinstance(saved, str) and saved.strip():
|
||||||
|
return _normalize_script_type(saved, ".specify/init-options.json")
|
||||||
|
return "ps" if os.name == "nt" else "sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_integration_script_type(
|
||||||
|
project_root: Path,
|
||||||
|
state: dict[str, Any],
|
||||||
|
key: str,
|
||||||
|
script_type: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve script type for an integration, preferring stored settings."""
|
||||||
|
if script_type:
|
||||||
|
return _normalize_script_type(script_type, "--script")
|
||||||
|
|
||||||
|
stored = _integration_setting(state, key).get("script")
|
||||||
|
if isinstance(stored, str) and stored.strip():
|
||||||
|
return _normalize_script_type(stored, f"{INTEGRATION_JSON} integration_settings.{key}.script")
|
||||||
|
|
||||||
|
return _resolve_script_type(project_root, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration options
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None:
|
||||||
|
"""Parse --integration-options string into a dict matching the integration's declared options.
|
||||||
|
|
||||||
|
Returns ``None`` when no options are provided.
|
||||||
|
"""
|
||||||
|
import shlex
|
||||||
|
parsed: dict[str, Any] = {}
|
||||||
|
tokens = shlex.split(raw_options)
|
||||||
|
declared_options = list(integration.options())
|
||||||
|
declared = {opt.name.lstrip("-"): opt for opt in declared_options}
|
||||||
|
allowed = ", ".join(sorted(opt.name for opt in declared_options))
|
||||||
|
i = 0
|
||||||
|
while i < len(tokens):
|
||||||
|
token = tokens[i]
|
||||||
|
if not token.startswith("-"):
|
||||||
|
console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.")
|
||||||
|
if allowed:
|
||||||
|
console.print(f"Allowed options: {allowed}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
name = token.lstrip("-")
|
||||||
|
value: str | None = None
|
||||||
|
# Handle --name=value syntax
|
||||||
|
if "=" in name:
|
||||||
|
name, value = name.split("=", 1)
|
||||||
|
opt = declared.get(name)
|
||||||
|
if not opt:
|
||||||
|
console.print(f"[red]Error:[/red] Unknown integration option '{token}'.")
|
||||||
|
if allowed:
|
||||||
|
console.print(f"Allowed options: {allowed}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
key = name.replace("-", "_")
|
||||||
|
if opt.is_flag:
|
||||||
|
if value is not None:
|
||||||
|
console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
parsed[key] = True
|
||||||
|
i += 1
|
||||||
|
elif value is not None:
|
||||||
|
parsed[key] = value
|
||||||
|
i += 1
|
||||||
|
elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
|
||||||
|
parsed[key] = tokens[i + 1]
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
return parsed or None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_integration_options(
|
||||||
|
integration: Any,
|
||||||
|
state: dict[str, Any],
|
||||||
|
key: str,
|
||||||
|
raw_options: str | None,
|
||||||
|
) -> tuple[str | None, dict[str, Any] | None]:
|
||||||
|
"""Resolve raw and parsed options for an integration operation."""
|
||||||
|
return _resolve_integration_options_impl(
|
||||||
|
integration,
|
||||||
|
state,
|
||||||
|
key,
|
||||||
|
raw_options,
|
||||||
|
parse_options=_parse_integration_options,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_init_options_for_integration(
|
||||||
|
project_root: Path,
|
||||||
|
integration: Any,
|
||||||
|
script_type: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update init-options.json and the agent-context extension config to
|
||||||
|
reflect *integration* as the active one.
|
||||||
|
|
||||||
|
``context_file`` 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; 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,
|
||||||
|
)
|
||||||
|
from .base import SkillsIntegration
|
||||||
|
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
|
||||||
|
if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False):
|
||||||
|
opts["ai_skills"] = True
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default integration persistence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _set_default_integration(
|
||||||
|
project_root: Path,
|
||||||
|
state: dict[str, Any],
|
||||||
|
key: str,
|
||||||
|
integration: Any,
|
||||||
|
installed_keys: list[str],
|
||||||
|
*,
|
||||||
|
script_type: str | None = None,
|
||||||
|
raw_options: str | None = None,
|
||||||
|
parsed_options: dict[str, Any] | None = None,
|
||||||
|
refresh_templates: bool = True,
|
||||||
|
refresh_templates_force: bool = False,
|
||||||
|
refresh_hint: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist *key* as default and align active runtime metadata."""
|
||||||
|
from .. import _install_shared_infra
|
||||||
|
resolved_script = _resolve_integration_script_type(project_root, state, key, script_type)
|
||||||
|
settings = _with_integration_setting(
|
||||||
|
state,
|
||||||
|
key,
|
||||||
|
integration,
|
||||||
|
script_type=resolved_script,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
)
|
||||||
|
|
||||||
|
if refresh_templates:
|
||||||
|
try:
|
||||||
|
_install_shared_infra(
|
||||||
|
project_root,
|
||||||
|
resolved_script,
|
||||||
|
invoke_separator=_invoke_separator_for_integration(
|
||||||
|
integration, {"integration_settings": settings}, key, parsed_options
|
||||||
|
),
|
||||||
|
force=refresh_templates_force,
|
||||||
|
refresh_managed=True,
|
||||||
|
refresh_hint=refresh_hint,
|
||||||
|
)
|
||||||
|
except (ValueError, OSError) as exc:
|
||||||
|
raise _SharedTemplateRefreshError(
|
||||||
|
f"Failed to refresh shared infrastructure for '{key}': {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
_write_integration_json(project_root, key, installed_keys, settings)
|
||||||
|
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
|
||||||
|
try:
|
||||||
|
_set_default_integration(*args, **kwargs)
|
||||||
|
except _SharedTemplateRefreshError as exc:
|
||||||
|
console.print(f"[red]Error:[/red] {exc}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI formatting helpers (re-exported from _commands.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _cli_error_detail(exc: BaseException) -> str:
|
||||||
|
"""Return a compact one-line exception detail for CLI output."""
|
||||||
|
return str(exc).replace("\n", " ").strip() or exc.__class__.__name__
|
||||||
|
|
||||||
|
|
||||||
|
def _cli_phase_label(phase: str, target_kind: str, target: str | None = None) -> str:
|
||||||
|
"""Format a stable operation label for user-visible diagnostics."""
|
||||||
|
label = f"{phase} {target_kind}".strip()
|
||||||
|
if target:
|
||||||
|
label = f"{label} '{target}'"
|
||||||
|
return label
|
||||||
309
src/specify_cli/integrations/_install_commands.py
Normal file
309
src/specify_cli/integrations/_install_commands.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"""specify integration install / uninstall command handlers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .._console import console
|
||||||
|
from .._utils import _display_project_path
|
||||||
|
from ..integration_runtime import (
|
||||||
|
invoke_separator_for_integration as _invoke_separator_for_integration,
|
||||||
|
with_integration_setting as _with_integration_setting,
|
||||||
|
)
|
||||||
|
from ..integration_state import (
|
||||||
|
dedupe_integration_keys as _dedupe_integration_keys,
|
||||||
|
default_integration_key as _default_integration_key,
|
||||||
|
installed_integration_keys as _installed_integration_keys,
|
||||||
|
integration_settings as _integration_settings,
|
||||||
|
)
|
||||||
|
from ._commands import integration_app
|
||||||
|
from ._helpers import (
|
||||||
|
_MANIFEST_READ_ERRORS,
|
||||||
|
_clear_init_options_for_integration,
|
||||||
|
_cli_error_detail,
|
||||||
|
_cli_phase_label,
|
||||||
|
_get_speckit_version,
|
||||||
|
_read_integration_json,
|
||||||
|
_refresh_init_options_speckit_version,
|
||||||
|
_remove_integration_json,
|
||||||
|
_resolve_integration_options,
|
||||||
|
_resolve_script_type,
|
||||||
|
_set_default_integration_or_exit,
|
||||||
|
_update_init_options_for_integration,
|
||||||
|
_write_integration_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@integration_app.command("install")
|
||||||
|
def integration_install(
|
||||||
|
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
|
||||||
|
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||||
|
force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"),
|
||||||
|
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||||
|
):
|
||||||
|
"""Install an integration into an existing project."""
|
||||||
|
from . import INTEGRATION_REGISTRY, get_integration
|
||||||
|
from .manifest import IntegrationManifest
|
||||||
|
from .. import _require_specify_project, _install_shared_infra_or_exit
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
integration = get_integration(key)
|
||||||
|
if integration is None:
|
||||||
|
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
||||||
|
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
|
||||||
|
console.print(f"Available integrations: {available}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
current = _read_integration_json(project_root)
|
||||||
|
default_key = _default_integration_key(current)
|
||||||
|
installed_keys = _installed_integration_keys(current)
|
||||||
|
|
||||||
|
if key in installed_keys:
|
||||||
|
console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]")
|
||||||
|
if default_key == key:
|
||||||
|
console.print("It is already the default integration.")
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
f"To make it the default integration, run "
|
||||||
|
f"[cyan]specify integration use {key}[/cyan]."
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"To refresh its managed files or options, run "
|
||||||
|
f"[cyan]specify integration upgrade {key}[/cyan]."
|
||||||
|
)
|
||||||
|
console.print("No files were changed.")
|
||||||
|
raise typer.Exit(0)
|
||||||
|
|
||||||
|
if installed_keys and not force:
|
||||||
|
unsafe_keys = []
|
||||||
|
for installed_key in installed_keys:
|
||||||
|
installed_integration = get_integration(installed_key)
|
||||||
|
if not installed_integration or not getattr(installed_integration, "multi_install_safe", False):
|
||||||
|
unsafe_keys.append(installed_key)
|
||||||
|
if unsafe_keys or not getattr(integration, "multi_install_safe", False):
|
||||||
|
console.print(
|
||||||
|
f"[red]Error:[/red] Installed integrations: {', '.join(installed_keys)}."
|
||||||
|
)
|
||||||
|
if default_key:
|
||||||
|
console.print(f"Default integration: [cyan]{default_key}[/cyan].")
|
||||||
|
console.print(
|
||||||
|
"Installing multiple integrations is only automatic when all involved "
|
||||||
|
"integrations are declared multi-install safe."
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"To replace the default integration, run "
|
||||||
|
f"[cyan]specify integration switch {key}[/cyan]."
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"To install '{key}' alongside the existing integrations anyway, "
|
||||||
|
"retry the same install command with [cyan]--force[/cyan]."
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
selected_script = _resolve_script_type(project_root, script)
|
||||||
|
|
||||||
|
# Build parsed options from --integration-options so the integration
|
||||||
|
# can determine its effective invoke separator before shared infra
|
||||||
|
# is installed.
|
||||||
|
raw_options, parsed_options = _resolve_integration_options(
|
||||||
|
integration, current, key, integration_options
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure shared infrastructure is present (safe to run unconditionally;
|
||||||
|
# _install_shared_infra merges missing files without overwriting).
|
||||||
|
infra_integration = integration
|
||||||
|
infra_key = key
|
||||||
|
infra_parsed = parsed_options
|
||||||
|
if default_key:
|
||||||
|
default_integration = get_integration(default_key)
|
||||||
|
if default_integration is not None:
|
||||||
|
infra_integration = default_integration
|
||||||
|
infra_key = default_key
|
||||||
|
_, infra_parsed = _resolve_integration_options(
|
||||||
|
default_integration, current, default_key, None
|
||||||
|
)
|
||||||
|
_install_shared_infra_or_exit(
|
||||||
|
project_root,
|
||||||
|
selected_script,
|
||||||
|
invoke_separator=_invoke_separator_for_integration(
|
||||||
|
infra_integration, current, infra_key, infra_parsed
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if os.name != "nt":
|
||||||
|
from .. import ensure_executable_scripts
|
||||||
|
ensure_executable_scripts(project_root)
|
||||||
|
|
||||||
|
manifest = IntegrationManifest(
|
||||||
|
integration.key, project_root, version=_get_speckit_version()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
integration.setup(
|
||||||
|
project_root, manifest,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
script_type=selected_script,
|
||||||
|
raw_options=raw_options,
|
||||||
|
)
|
||||||
|
manifest.save()
|
||||||
|
new_installed = _dedupe_integration_keys([*installed_keys, integration.key])
|
||||||
|
new_default = default_key or integration.key
|
||||||
|
settings = _with_integration_setting(
|
||||||
|
current,
|
||||||
|
integration.key,
|
||||||
|
integration,
|
||||||
|
script_type=selected_script,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
)
|
||||||
|
_write_integration_json(project_root, new_default, new_installed, settings)
|
||||||
|
if new_default == integration.key:
|
||||||
|
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
||||||
|
else:
|
||||||
|
_refresh_init_options_speckit_version(project_root)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
# Attempt rollback of any files written by setup
|
||||||
|
try:
|
||||||
|
integration.teardown(project_root, manifest, force=True)
|
||||||
|
except Exception as rollback_err:
|
||||||
|
# Suppress so the original setup error remains the primary failure
|
||||||
|
from .. import _print_cli_warning
|
||||||
|
_print_cli_warning(
|
||||||
|
"rollback",
|
||||||
|
"integration",
|
||||||
|
key,
|
||||||
|
rollback_err,
|
||||||
|
continuing="The original install failure is still the primary error.",
|
||||||
|
)
|
||||||
|
if installed_keys:
|
||||||
|
_write_integration_json(
|
||||||
|
project_root, default_key, installed_keys, _integration_settings(current)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_remove_integration_json(project_root)
|
||||||
|
console.print(
|
||||||
|
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', key)}: "
|
||||||
|
f"{_cli_error_detail(exc)}"
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
name = (integration.config or {}).get("name", key)
|
||||||
|
console.print(f"\n[green]✓[/green] Integration '{name}' installed successfully")
|
||||||
|
if default_key:
|
||||||
|
console.print(f"[dim]Default integration remains:[/dim] [cyan]{default_key}[/cyan]")
|
||||||
|
|
||||||
|
|
||||||
|
@integration_app.command("uninstall")
|
||||||
|
def integration_uninstall(
|
||||||
|
key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"),
|
||||||
|
force: bool = typer.Option(False, "--force", help="Remove files even if modified"),
|
||||||
|
):
|
||||||
|
"""Uninstall an integration, safely preserving modified files."""
|
||||||
|
from . import get_integration
|
||||||
|
from .manifest import IntegrationManifest
|
||||||
|
from .. import _require_specify_project
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
current = _read_integration_json(project_root)
|
||||||
|
default_key = _default_integration_key(current)
|
||||||
|
installed_keys = _installed_integration_keys(current)
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
if not default_key:
|
||||||
|
console.print("[yellow]No integration is currently installed.[/yellow]")
|
||||||
|
raise typer.Exit(0)
|
||||||
|
key = default_key
|
||||||
|
|
||||||
|
if key not in installed_keys:
|
||||||
|
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
integration = get_integration(key)
|
||||||
|
|
||||||
|
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
|
||||||
|
if not manifest_path.exists():
|
||||||
|
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to uninstall.[/yellow]")
|
||||||
|
remaining = [installed for installed in installed_keys if installed != key]
|
||||||
|
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
|
||||||
|
if remaining:
|
||||||
|
if default_key == key and new_default and (new_integration := get_integration(new_default)):
|
||||||
|
raw_options, parsed_options = _resolve_integration_options(
|
||||||
|
new_integration, current, new_default, None
|
||||||
|
)
|
||||||
|
_set_default_integration_or_exit(
|
||||||
|
project_root,
|
||||||
|
current,
|
||||||
|
new_default,
|
||||||
|
new_integration,
|
||||||
|
remaining,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_write_integration_json(
|
||||||
|
project_root, new_default, remaining, _integration_settings(current)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_remove_integration_json(project_root)
|
||||||
|
if default_key == key:
|
||||||
|
_clear_init_options_for_integration(project_root, key)
|
||||||
|
raise typer.Exit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = IntegrationManifest.load(key, project_root)
|
||||||
|
except _MANIFEST_READ_ERRORS as exc:
|
||||||
|
console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable.")
|
||||||
|
console.print(f"Manifest: {manifest_path}")
|
||||||
|
console.print(
|
||||||
|
f"To recover, delete the unreadable manifest, run "
|
||||||
|
f"[cyan]specify integration uninstall {key}[/cyan] to clear stale metadata, "
|
||||||
|
f"then run [cyan]specify integration install {key}[/cyan] to regenerate."
|
||||||
|
)
|
||||||
|
console.print(f"[dim]Details:[/dim] {exc}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if not integration:
|
||||||
|
console.print(
|
||||||
|
f"[yellow]Warning:[/yellow] Integration '{key}' not found "
|
||||||
|
"in registry. Falling back to manifest-based cleanup."
|
||||||
|
)
|
||||||
|
removed, skipped = manifest.uninstall(project_root, force=force)
|
||||||
|
else:
|
||||||
|
removed, skipped = integration.teardown(project_root, manifest, force=force)
|
||||||
|
|
||||||
|
remaining = [installed for installed in installed_keys if installed != key]
|
||||||
|
new_default = default_key if default_key != key else (remaining[0] if remaining else None)
|
||||||
|
if remaining:
|
||||||
|
if default_key == key and new_default and (new_integration := get_integration(new_default)):
|
||||||
|
raw_options, parsed_options = _resolve_integration_options(
|
||||||
|
new_integration, current, new_default, None
|
||||||
|
)
|
||||||
|
_set_default_integration_or_exit(
|
||||||
|
project_root,
|
||||||
|
current,
|
||||||
|
new_default,
|
||||||
|
new_integration,
|
||||||
|
remaining,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_write_integration_json(
|
||||||
|
project_root, new_default, remaining, _integration_settings(current)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_remove_integration_json(project_root)
|
||||||
|
|
||||||
|
if default_key == key:
|
||||||
|
_clear_init_options_for_integration(project_root, key)
|
||||||
|
|
||||||
|
name = (integration.config or {}).get("name", key) if integration else key
|
||||||
|
console.print(f"\n[green]✓[/green] Integration '{name}' uninstalled")
|
||||||
|
if removed:
|
||||||
|
console.print(f" Removed {len(removed)} file(s)")
|
||||||
|
if skipped:
|
||||||
|
console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:")
|
||||||
|
for path in skipped:
|
||||||
|
rel = _display_project_path(project_root, path)
|
||||||
|
console.print(f" {rel}")
|
||||||
490
src/specify_cli/integrations/_migrate_commands.py
Normal file
490
src/specify_cli/integrations/_migrate_commands.py
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
"""specify integration switch / upgrade command handlers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .._console import console
|
||||||
|
from ..integration_runtime import (
|
||||||
|
invoke_separator_for_integration as _invoke_separator_for_integration,
|
||||||
|
with_integration_setting as _with_integration_setting,
|
||||||
|
)
|
||||||
|
from ..integration_state import (
|
||||||
|
dedupe_integration_keys as _dedupe_integration_keys,
|
||||||
|
default_integration_key as _default_integration_key,
|
||||||
|
installed_integration_keys as _installed_integration_keys,
|
||||||
|
integration_settings as _integration_settings,
|
||||||
|
)
|
||||||
|
from ._commands import integration_app
|
||||||
|
from ._helpers import (
|
||||||
|
_MANIFEST_READ_ERRORS,
|
||||||
|
_SharedTemplateRefreshError,
|
||||||
|
_clear_init_options_for_integration,
|
||||||
|
_cli_error_detail,
|
||||||
|
_cli_phase_label,
|
||||||
|
_get_speckit_version,
|
||||||
|
_read_integration_json,
|
||||||
|
_refresh_init_options_speckit_version,
|
||||||
|
_remove_integration_json,
|
||||||
|
_resolve_integration_options,
|
||||||
|
_resolve_integration_script_type,
|
||||||
|
_resolve_script_type,
|
||||||
|
_set_default_integration,
|
||||||
|
_set_default_integration_or_exit,
|
||||||
|
_update_init_options_for_integration,
|
||||||
|
_write_integration_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@integration_app.command("switch")
|
||||||
|
def integration_switch(
|
||||||
|
target: str = typer.Argument(help="Integration key to switch to"),
|
||||||
|
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||||
|
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
|
||||||
|
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
|
||||||
|
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
|
||||||
|
):
|
||||||
|
"""Switch from the current integration to a different one."""
|
||||||
|
from . import INTEGRATION_REGISTRY, get_integration
|
||||||
|
from .manifest import IntegrationManifest
|
||||||
|
from .. import _print_cli_warning, _require_specify_project, _install_shared_infra_or_exit
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
target_integration = get_integration(target)
|
||||||
|
if target_integration is None:
|
||||||
|
console.print(f"[red]Error:[/red] Unknown integration '{target}'")
|
||||||
|
available = ", ".join(sorted(INTEGRATION_REGISTRY.keys()))
|
||||||
|
console.print(f"Available integrations: {available}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
current = _read_integration_json(project_root)
|
||||||
|
installed_keys = _installed_integration_keys(current)
|
||||||
|
installed_key = _default_integration_key(current)
|
||||||
|
|
||||||
|
if installed_key == target:
|
||||||
|
if integration_options is not None:
|
||||||
|
console.print(
|
||||||
|
"[red]Error:[/red] --integration-options cannot be used when switching "
|
||||||
|
"to an already installed integration."
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
|
||||||
|
"to update managed files/options."
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
if force:
|
||||||
|
raw_options, parsed_options = _resolve_integration_options(
|
||||||
|
target_integration, current, target, None
|
||||||
|
)
|
||||||
|
_set_default_integration_or_exit(
|
||||||
|
project_root,
|
||||||
|
current,
|
||||||
|
target,
|
||||||
|
target_integration,
|
||||||
|
installed_keys,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
refresh_templates_force=True,
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"\n[green]✓[/green] Default integration remains [bold]{target}[/bold]; "
|
||||||
|
"shared infrastructure refreshed."
|
||||||
|
)
|
||||||
|
raise typer.Exit(0)
|
||||||
|
console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]")
|
||||||
|
raise typer.Exit(0)
|
||||||
|
|
||||||
|
if target in installed_keys:
|
||||||
|
if integration_options is not None:
|
||||||
|
console.print(
|
||||||
|
"[red]Error:[/red] --integration-options cannot be used when switching "
|
||||||
|
"to an already installed integration."
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"Run [cyan]specify integration upgrade {target} --integration-options ...[/cyan] "
|
||||||
|
f"to update managed files/options, then [cyan]specify integration use {target}[/cyan]."
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
raw_options, parsed_options = _resolve_integration_options(
|
||||||
|
target_integration, current, target, None
|
||||||
|
)
|
||||||
|
_set_default_integration_or_exit(
|
||||||
|
project_root,
|
||||||
|
current,
|
||||||
|
target,
|
||||||
|
target_integration,
|
||||||
|
installed_keys,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
refresh_templates_force=force,
|
||||||
|
)
|
||||||
|
console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].")
|
||||||
|
raise typer.Exit(0)
|
||||||
|
|
||||||
|
selected_script = _resolve_script_type(project_root, script)
|
||||||
|
|
||||||
|
# Phase 1: Uninstall current integration (if any)
|
||||||
|
if installed_key:
|
||||||
|
current_integration = get_integration(installed_key)
|
||||||
|
manifest_path = project_root / ".specify" / "integrations" / f"{installed_key}.manifest.json"
|
||||||
|
|
||||||
|
if current_integration and manifest_path.exists():
|
||||||
|
console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]")
|
||||||
|
try:
|
||||||
|
old_manifest = IntegrationManifest.load(installed_key, project_root)
|
||||||
|
except _MANIFEST_READ_ERRORS as exc:
|
||||||
|
console.print(f"[red]Error:[/red] Could not read integration manifest for '{installed_key}': {manifest_path}")
|
||||||
|
console.print(f"[dim]{exc}[/dim]")
|
||||||
|
console.print(
|
||||||
|
f"To recover, delete the unreadable manifest at {manifest_path}, "
|
||||||
|
f"run [cyan]specify integration uninstall {installed_key}[/cyan], then retry."
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
removed, skipped = current_integration.teardown(
|
||||||
|
project_root, old_manifest, force=force,
|
||||||
|
)
|
||||||
|
if removed:
|
||||||
|
console.print(f" Removed {len(removed)} file(s)")
|
||||||
|
if skipped:
|
||||||
|
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
|
||||||
|
elif not current_integration and manifest_path.exists():
|
||||||
|
# Integration removed from registry but manifest exists — use manifest-only uninstall
|
||||||
|
console.print(f"Uninstalling unknown integration '{installed_key}' via manifest")
|
||||||
|
try:
|
||||||
|
old_manifest = IntegrationManifest.load(installed_key, project_root)
|
||||||
|
removed, skipped = old_manifest.uninstall(project_root, force=force)
|
||||||
|
if removed:
|
||||||
|
console.print(f" Removed {len(removed)} file(s)")
|
||||||
|
if skipped:
|
||||||
|
console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved")
|
||||||
|
except _MANIFEST_READ_ERRORS as exc:
|
||||||
|
console.print(f"[yellow]Warning:[/yellow] Could not read manifest for '{installed_key}': {exc}")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Error:[/red] Integration '{installed_key}' is installed but has no manifest.")
|
||||||
|
console.print(
|
||||||
|
f"Run [cyan]specify integration uninstall {installed_key}[/cyan] to clear metadata, "
|
||||||
|
f"then retry [cyan]specify integration switch {target}[/cyan]."
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Unregister extension commands for the old agent so they don't
|
||||||
|
# remain as orphans in the old agent's directory.
|
||||||
|
try:
|
||||||
|
from ..extensions import ExtensionManager
|
||||||
|
|
||||||
|
ext_mgr = ExtensionManager(project_root)
|
||||||
|
ext_mgr.unregister_agent_artifacts(installed_key)
|
||||||
|
except Exception as ext_err:
|
||||||
|
_print_cli_warning(
|
||||||
|
"clean up extension artifacts for",
|
||||||
|
"integration",
|
||||||
|
installed_key,
|
||||||
|
ext_err,
|
||||||
|
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear metadata so a failed Phase 2 doesn't leave stale references
|
||||||
|
installed_keys = [installed for installed in installed_keys if installed != installed_key]
|
||||||
|
_clear_init_options_for_integration(project_root, installed_key)
|
||||||
|
if installed_keys:
|
||||||
|
fallback_key = installed_keys[0]
|
||||||
|
fallback_integration = get_integration(fallback_key)
|
||||||
|
if fallback_integration is not None:
|
||||||
|
raw_options, parsed_options = _resolve_integration_options(
|
||||||
|
fallback_integration, current, fallback_key, None
|
||||||
|
)
|
||||||
|
_set_default_integration_or_exit(
|
||||||
|
project_root,
|
||||||
|
current,
|
||||||
|
fallback_key,
|
||||||
|
fallback_integration,
|
||||||
|
installed_keys,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_write_integration_json(
|
||||||
|
project_root, fallback_key, installed_keys, _integration_settings(current)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_remove_integration_json(project_root)
|
||||||
|
current = _read_integration_json(project_root)
|
||||||
|
|
||||||
|
# Build parsed options from --integration-options so the integration
|
||||||
|
# can determine its effective invoke separator before shared infra
|
||||||
|
# is installed.
|
||||||
|
raw_options, parsed_options = _resolve_integration_options(
|
||||||
|
target_integration, current, target, integration_options
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh shared infrastructure to the current CLI version. Switching
|
||||||
|
# integrations is exactly when stale vendored shared scripts (e.g.
|
||||||
|
# update-agent-context.sh that pre-dates the target integration's
|
||||||
|
# supported-agent list) would silently break the new integration.
|
||||||
|
#
|
||||||
|
# Use refresh_managed=True so only files that match their previously
|
||||||
|
# recorded hash are overwritten — user customizations are detected via
|
||||||
|
# hash divergence and preserved with a warning. Pass
|
||||||
|
# --refresh-shared-infra to overwrite customizations as well. See #2293.
|
||||||
|
_install_shared_infra_or_exit(
|
||||||
|
project_root,
|
||||||
|
selected_script,
|
||||||
|
force=refresh_shared_infra,
|
||||||
|
refresh_managed=True,
|
||||||
|
invoke_separator=_invoke_separator_for_integration(
|
||||||
|
target_integration, current, target, parsed_options
|
||||||
|
),
|
||||||
|
refresh_hint=(
|
||||||
|
"To overwrite customizations, re-run with "
|
||||||
|
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if os.name != "nt":
|
||||||
|
from .. import ensure_executable_scripts
|
||||||
|
ensure_executable_scripts(project_root)
|
||||||
|
|
||||||
|
# Phase 2: Install target integration
|
||||||
|
console.print(f"Installing integration: [cyan]{target}[/cyan]")
|
||||||
|
manifest = IntegrationManifest(
|
||||||
|
target_integration.key, project_root, version=_get_speckit_version()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_integration.setup(
|
||||||
|
project_root, manifest,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
script_type=selected_script,
|
||||||
|
raw_options=raw_options,
|
||||||
|
)
|
||||||
|
manifest.save()
|
||||||
|
_set_default_integration(
|
||||||
|
project_root,
|
||||||
|
current,
|
||||||
|
target_integration.key,
|
||||||
|
target_integration,
|
||||||
|
_dedupe_integration_keys([*installed_keys, target_integration.key]),
|
||||||
|
script_type=selected_script,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-register extension commands for the new agent so that
|
||||||
|
# previously-installed extensions are available in the new integration.
|
||||||
|
try:
|
||||||
|
from ..extensions import ExtensionManager
|
||||||
|
|
||||||
|
ext_mgr = ExtensionManager(project_root)
|
||||||
|
ext_mgr.register_enabled_extensions_for_agent(target)
|
||||||
|
except Exception as ext_err:
|
||||||
|
_print_cli_warning(
|
||||||
|
"register extension artifacts for",
|
||||||
|
"integration",
|
||||||
|
target,
|
||||||
|
ext_err,
|
||||||
|
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
# Attempt rollback of any files written by setup
|
||||||
|
try:
|
||||||
|
target_integration.teardown(project_root, manifest, force=True)
|
||||||
|
except Exception as rollback_err:
|
||||||
|
# Suppress so the original setup error remains the primary failure
|
||||||
|
_print_cli_warning(
|
||||||
|
"rollback",
|
||||||
|
"integration",
|
||||||
|
target,
|
||||||
|
rollback_err,
|
||||||
|
continuing="The original switch failure is still the primary error.",
|
||||||
|
)
|
||||||
|
if installed_keys:
|
||||||
|
fallback_key = installed_keys[0]
|
||||||
|
fallback_integration = get_integration(fallback_key)
|
||||||
|
if fallback_integration is not None:
|
||||||
|
raw_options, parsed_options = _resolve_integration_options(
|
||||||
|
fallback_integration, current, fallback_key, None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_set_default_integration(
|
||||||
|
project_root,
|
||||||
|
current,
|
||||||
|
fallback_key,
|
||||||
|
fallback_integration,
|
||||||
|
installed_keys,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
)
|
||||||
|
except _SharedTemplateRefreshError as restore_err:
|
||||||
|
console.print(
|
||||||
|
f"[yellow]Warning:[/yellow] Failed to restore default "
|
||||||
|
f"integration '{fallback_key}': {restore_err}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_write_integration_json(
|
||||||
|
project_root, fallback_key, installed_keys, _integration_settings(current)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_remove_integration_json(project_root)
|
||||||
|
console.print(
|
||||||
|
f"[red]Error:[/red] Failed to {_cli_phase_label('install', 'integration', target)} "
|
||||||
|
f"during switch: {_cli_error_detail(exc)}"
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
name = (target_integration.config or {}).get("name", target)
|
||||||
|
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
|
||||||
|
|
||||||
|
|
||||||
|
@integration_app.command("upgrade")
|
||||||
|
def integration_upgrade(
|
||||||
|
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
|
||||||
|
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
|
||||||
|
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||||
|
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
|
||||||
|
):
|
||||||
|
"""Upgrade an integration by reinstalling with diff-aware file handling.
|
||||||
|
|
||||||
|
Compares manifest hashes to detect locally modified files and
|
||||||
|
blocks the upgrade unless --force is used.
|
||||||
|
"""
|
||||||
|
from . import get_integration
|
||||||
|
from .manifest import IntegrationManifest
|
||||||
|
from .. import _require_specify_project, _install_shared_infra_or_exit, _install_shared_infra
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
current = _read_integration_json(project_root)
|
||||||
|
installed_key = _default_integration_key(current)
|
||||||
|
installed_keys = _installed_integration_keys(current)
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
if not installed_key:
|
||||||
|
console.print("[yellow]No integration is currently installed.[/yellow]")
|
||||||
|
raise typer.Exit(0)
|
||||||
|
key = installed_key
|
||||||
|
|
||||||
|
if key not in installed_keys:
|
||||||
|
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
integration = get_integration(key)
|
||||||
|
if integration is None:
|
||||||
|
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
manifest_path = project_root / ".specify" / "integrations" / f"{key}.manifest.json"
|
||||||
|
if not manifest_path.exists():
|
||||||
|
console.print(f"[yellow]No manifest found for integration '{key}'. Nothing to upgrade.[/yellow]")
|
||||||
|
console.print(f"Run [cyan]specify integration install {key}[/cyan] to perform a fresh install.")
|
||||||
|
raise typer.Exit(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
old_manifest = IntegrationManifest.load(key, project_root)
|
||||||
|
except _MANIFEST_READ_ERRORS as exc:
|
||||||
|
console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Detect modified files via manifest hashes
|
||||||
|
modified = old_manifest.check_modified()
|
||||||
|
if modified and not force:
|
||||||
|
console.print(f"[yellow]⚠[/yellow] {len(modified)} file(s) have been modified since installation:")
|
||||||
|
for rel in modified:
|
||||||
|
console.print(f" {rel}")
|
||||||
|
console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
selected_script = _resolve_integration_script_type(project_root, current, key, script)
|
||||||
|
|
||||||
|
# Build parsed options from --integration-options so the integration
|
||||||
|
# can determine its effective invoke separator before shared infra
|
||||||
|
# is installed.
|
||||||
|
raw_options, parsed_options = _resolve_integration_options(
|
||||||
|
integration, current, key, integration_options
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure shared infrastructure is up to date; --force overwrites existing files.
|
||||||
|
infra_integration = integration
|
||||||
|
infra_key = key
|
||||||
|
infra_parsed = parsed_options
|
||||||
|
if installed_key and installed_key != key:
|
||||||
|
default_integration = get_integration(installed_key)
|
||||||
|
if default_integration is not None:
|
||||||
|
infra_integration = default_integration
|
||||||
|
infra_key = installed_key
|
||||||
|
_, infra_parsed = _resolve_integration_options(
|
||||||
|
default_integration, current, installed_key, None
|
||||||
|
)
|
||||||
|
_install_shared_infra_or_exit(
|
||||||
|
project_root,
|
||||||
|
selected_script,
|
||||||
|
force=force,
|
||||||
|
invoke_separator=_invoke_separator_for_integration(
|
||||||
|
infra_integration, current, infra_key, infra_parsed
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if os.name != "nt":
|
||||||
|
from .. import ensure_executable_scripts
|
||||||
|
ensure_executable_scripts(project_root)
|
||||||
|
|
||||||
|
# Phase 1: Install new files (overwrites existing; old-only files remain)
|
||||||
|
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
|
||||||
|
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version())
|
||||||
|
|
||||||
|
try:
|
||||||
|
integration.setup(
|
||||||
|
project_root,
|
||||||
|
new_manifest,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
script_type=selected_script,
|
||||||
|
raw_options=raw_options,
|
||||||
|
)
|
||||||
|
settings = _with_integration_setting(
|
||||||
|
current,
|
||||||
|
key,
|
||||||
|
integration,
|
||||||
|
script_type=selected_script,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
)
|
||||||
|
if installed_key == key:
|
||||||
|
try:
|
||||||
|
_install_shared_infra(
|
||||||
|
project_root,
|
||||||
|
selected_script,
|
||||||
|
invoke_separator=_invoke_separator_for_integration(
|
||||||
|
integration, {"integration_settings": settings}, key, parsed_options
|
||||||
|
),
|
||||||
|
force=force,
|
||||||
|
refresh_managed=True,
|
||||||
|
)
|
||||||
|
except (ValueError, OSError) as exc:
|
||||||
|
raise _SharedTemplateRefreshError(
|
||||||
|
f"Failed to refresh shared infrastructure for '{key}': {exc}"
|
||||||
|
) from exc
|
||||||
|
new_manifest.save()
|
||||||
|
_write_integration_json(project_root, installed_key, installed_keys, settings)
|
||||||
|
if installed_key == key:
|
||||||
|
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
||||||
|
else:
|
||||||
|
_refresh_init_options_speckit_version(project_root)
|
||||||
|
except Exception as exc:
|
||||||
|
# Don't teardown — setup overwrites in-place, so teardown would
|
||||||
|
# delete files that were working before the upgrade. Just report.
|
||||||
|
console.print(f"[red]Error:[/red] Failed to {_cli_phase_label('upgrade', 'integration', key)}.")
|
||||||
|
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
|
||||||
|
console.print("[yellow]The previous integration files may still be in place.[/yellow]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
# Phase 2: Remove stale files from old manifest that are not in the new one
|
||||||
|
old_files = old_manifest.files
|
||||||
|
new_files = new_manifest.files
|
||||||
|
stale_keys = set(old_files) - set(new_files)
|
||||||
|
if stale_keys:
|
||||||
|
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
|
||||||
|
stale_manifest._files = {k: old_files[k] for k in stale_keys}
|
||||||
|
stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
|
||||||
|
if stale_removed:
|
||||||
|
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
|
||||||
|
|
||||||
|
name = (integration.config or {}).get("name", key)
|
||||||
|
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
|
||||||
464
src/specify_cli/integrations/_query_commands.py
Normal file
464
src/specify_cli/integrations/_query_commands.py
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
"""specify integration list/use/search/info + catalog list/add/remove command handlers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from .._console import console
|
||||||
|
from ..integration_state import (
|
||||||
|
default_integration_key as _default_integration_key,
|
||||||
|
installed_integration_keys as _installed_integration_keys,
|
||||||
|
)
|
||||||
|
from ._commands import integration_app, integration_catalog_app
|
||||||
|
from ._helpers import (
|
||||||
|
_read_integration_json,
|
||||||
|
_resolve_integration_options,
|
||||||
|
_set_default_integration_or_exit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@integration_app.command("list")
|
||||||
|
def integration_list(
|
||||||
|
catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"),
|
||||||
|
):
|
||||||
|
"""List available integrations and installed status."""
|
||||||
|
from . import INTEGRATION_REGISTRY
|
||||||
|
from .. import _require_specify_project
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
current = _read_integration_json(project_root)
|
||||||
|
default_key = _default_integration_key(current)
|
||||||
|
installed_keys = set(_installed_integration_keys(current))
|
||||||
|
|
||||||
|
if catalog:
|
||||||
|
from .catalog import IntegrationCatalog, IntegrationCatalogError
|
||||||
|
|
||||||
|
ic = IntegrationCatalog(project_root)
|
||||||
|
try:
|
||||||
|
entries = ic.search()
|
||||||
|
except IntegrationCatalogError as exc:
|
||||||
|
console.print(f"[red]Error:[/red] {exc}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
console.print("[yellow]No integrations found in catalog.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
table = Table(title="Integration Catalog")
|
||||||
|
table.add_column("ID", style="cyan")
|
||||||
|
table.add_column("Name")
|
||||||
|
table.add_column("Version")
|
||||||
|
table.add_column("Source")
|
||||||
|
table.add_column("Status")
|
||||||
|
table.add_column("Multi-install Safe")
|
||||||
|
|
||||||
|
for entry in sorted(entries, key=lambda e: e["id"]):
|
||||||
|
eid = entry["id"]
|
||||||
|
cat_name = entry.get("_catalog_name", "")
|
||||||
|
install_allowed = entry.get("_install_allowed", True)
|
||||||
|
if eid == default_key:
|
||||||
|
status = "[green]installed (default)[/green]"
|
||||||
|
elif eid in installed_keys:
|
||||||
|
status = "[green]installed[/green]"
|
||||||
|
elif eid in INTEGRATION_REGISTRY:
|
||||||
|
status = "built-in"
|
||||||
|
elif install_allowed is False:
|
||||||
|
status = "discovery-only"
|
||||||
|
else:
|
||||||
|
status = ""
|
||||||
|
safe = ""
|
||||||
|
if eid in INTEGRATION_REGISTRY:
|
||||||
|
reg_integ = INTEGRATION_REGISTRY[eid]
|
||||||
|
safe = "yes" if getattr(reg_integ, "multi_install_safe", False) else "no"
|
||||||
|
table.add_row(
|
||||||
|
eid,
|
||||||
|
entry.get("name", eid),
|
||||||
|
entry.get("version", ""),
|
||||||
|
cat_name,
|
||||||
|
status,
|
||||||
|
safe,
|
||||||
|
)
|
||||||
|
console.print(table)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not INTEGRATION_REGISTRY:
|
||||||
|
console.print("[yellow]No integrations available.[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
table = Table(title="Coding Agent Integrations")
|
||||||
|
table.add_column("Key", style="cyan")
|
||||||
|
table.add_column("Name")
|
||||||
|
table.add_column("Status")
|
||||||
|
table.add_column("CLI Required")
|
||||||
|
table.add_column("Multi-install Safe")
|
||||||
|
|
||||||
|
for key in sorted(INTEGRATION_REGISTRY.keys()):
|
||||||
|
integration = INTEGRATION_REGISTRY[key]
|
||||||
|
cfg = integration.config or {}
|
||||||
|
name = cfg.get("name", key)
|
||||||
|
requires_cli = cfg.get("requires_cli", False)
|
||||||
|
if key == default_key:
|
||||||
|
status = "[green]installed (default)[/green]"
|
||||||
|
elif key in installed_keys:
|
||||||
|
status = "[green]installed[/green]"
|
||||||
|
else:
|
||||||
|
status = ""
|
||||||
|
cli_req = "yes" if requires_cli else "no (IDE)"
|
||||||
|
safe = "yes" if getattr(integration, "multi_install_safe", False) else "no"
|
||||||
|
table.add_row(key, name, status, cli_req, safe)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
if installed_keys:
|
||||||
|
console.print(f"\n[dim]Default integration:[/dim] [cyan]{default_key or 'none'}[/cyan]")
|
||||||
|
console.print(f"[dim]Installed integrations:[/dim] [cyan]{', '.join(sorted(installed_keys))}[/cyan]")
|
||||||
|
else:
|
||||||
|
console.print("\n[yellow]No integration currently installed.[/yellow]")
|
||||||
|
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
|
||||||
|
|
||||||
|
|
||||||
|
@integration_app.command("use")
|
||||||
|
def integration_use(
|
||||||
|
key: str = typer.Argument(help="Installed integration key to make the default"),
|
||||||
|
force: bool = typer.Option(False, "--force", help="Overwrite existing shared infrastructure files, including customizations, while changing the default"),
|
||||||
|
):
|
||||||
|
"""Set the default integration without uninstalling other integrations."""
|
||||||
|
from . import get_integration
|
||||||
|
from .. import _require_specify_project
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
current = _read_integration_json(project_root)
|
||||||
|
installed_keys = _installed_integration_keys(current)
|
||||||
|
if key not in installed_keys:
|
||||||
|
console.print(f"[red]Error:[/red] Integration '{key}' is not installed.")
|
||||||
|
if installed_keys:
|
||||||
|
console.print(f"[yellow]Installed integrations:[/yellow] {', '.join(installed_keys)}")
|
||||||
|
else:
|
||||||
|
console.print("Install one with: [cyan]specify integration install <key>[/cyan]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
integration = get_integration(key)
|
||||||
|
if integration is None:
|
||||||
|
console.print(f"[red]Error:[/red] Unknown integration '{key}'")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
raw_options, parsed_options = _resolve_integration_options(integration, current, key, None)
|
||||||
|
_set_default_integration_or_exit(
|
||||||
|
project_root,
|
||||||
|
current,
|
||||||
|
key,
|
||||||
|
integration,
|
||||||
|
installed_keys,
|
||||||
|
raw_options=raw_options,
|
||||||
|
parsed_options=parsed_options,
|
||||||
|
refresh_templates_force=force,
|
||||||
|
refresh_hint=(
|
||||||
|
"To overwrite customizations, re-run with "
|
||||||
|
f"[cyan]specify integration use {key} --force[/cyan]."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Integration catalog discovery commands =====
|
||||||
|
#
|
||||||
|
# These commands mirror the workflow catalog CLI shape:
|
||||||
|
# - `search` / `info` for discovery over the active catalog stack
|
||||||
|
# - `catalog list/add/remove` for managing catalog sources
|
||||||
|
#
|
||||||
|
# They deliberately do NOT add `integration add/remove/enable/disable/
|
||||||
|
# set-priority`: integrations are single-active (install / uninstall / switch),
|
||||||
|
# not additive like extensions and presets.
|
||||||
|
@integration_app.command("search")
|
||||||
|
def integration_search(
|
||||||
|
query: Optional[str] = typer.Argument(None, help="Search query (optional)"),
|
||||||
|
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
|
||||||
|
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
|
||||||
|
):
|
||||||
|
"""Search for integrations in the active catalog stack."""
|
||||||
|
from . import INTEGRATION_REGISTRY
|
||||||
|
from .catalog import (
|
||||||
|
IntegrationCatalog,
|
||||||
|
IntegrationCatalogError,
|
||||||
|
IntegrationValidationError,
|
||||||
|
)
|
||||||
|
from .. import _require_specify_project
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
integration_config = _read_integration_json(project_root)
|
||||||
|
installed_key = _default_integration_key(integration_config)
|
||||||
|
catalog = IntegrationCatalog(project_root)
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = catalog.search(query=query, tag=tag, author=author)
|
||||||
|
except IntegrationValidationError as exc:
|
||||||
|
console.print(f"[red]Error:[/red] {exc}")
|
||||||
|
console.print(
|
||||||
|
"\nTip: Check the configuration file path shown above for invalid catalog configuration "
|
||||||
|
"(for example, .specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
|
||||||
|
)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
except IntegrationCatalogError as exc:
|
||||||
|
console.print(f"[red]Error:[/red] {exc}")
|
||||||
|
if os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
|
||||||
|
console.print(
|
||||||
|
"\nTip: Check the SPECKIT_INTEGRATION_CATALOG_URL environment variable for an invalid "
|
||||||
|
"catalog URL, or unset it to use the configured catalog files "
|
||||||
|
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml)."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print("\nTip: The catalog may be temporarily unavailable. Try again later.")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
console.print("\n[yellow]No integrations found matching criteria[/yellow]")
|
||||||
|
if query or tag or author:
|
||||||
|
console.print("\nTry:")
|
||||||
|
console.print(" • Broader search terms")
|
||||||
|
console.print(" • Remove filters")
|
||||||
|
console.print(" • specify integration search (show all)")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
|
||||||
|
for integ in sorted(results, key=lambda e: e.get("id", "")):
|
||||||
|
iid = integ.get("id", "?")
|
||||||
|
name = integ.get("name", iid)
|
||||||
|
version = integ.get("version", "?")
|
||||||
|
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
|
||||||
|
desc = integ.get("description", "")
|
||||||
|
if desc:
|
||||||
|
console.print(f" {desc}")
|
||||||
|
|
||||||
|
console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
|
||||||
|
tags = integ.get("tags", [])
|
||||||
|
if isinstance(tags, list) and tags:
|
||||||
|
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
|
||||||
|
|
||||||
|
cat_name = integ.get("_catalog_name", "")
|
||||||
|
install_allowed = integ.get("_install_allowed", True)
|
||||||
|
if cat_name:
|
||||||
|
if install_allowed:
|
||||||
|
console.print(f" [dim]Catalog:[/dim] {cat_name}")
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
f" [dim]Catalog:[/dim] {cat_name} "
|
||||||
|
"[yellow](discovery only — not installable)[/yellow]"
|
||||||
|
)
|
||||||
|
|
||||||
|
if iid == installed_key:
|
||||||
|
console.print("\n [green]✓ Installed[/green] (currently active)")
|
||||||
|
elif iid in INTEGRATION_REGISTRY:
|
||||||
|
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
|
||||||
|
elif install_allowed:
|
||||||
|
console.print(
|
||||||
|
"\n [yellow]Found in catalog.[/yellow] Only built-in integration IDs "
|
||||||
|
"can be installed with 'specify integration install'."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
f"\n [yellow]⚠[/yellow] Not directly installable from '{cat_name}'."
|
||||||
|
)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
@integration_app.command("info")
|
||||||
|
def integration_info(
|
||||||
|
integration_id: str = typer.Argument(..., help="Integration ID"),
|
||||||
|
):
|
||||||
|
"""Show catalog details for a single integration."""
|
||||||
|
from . import INTEGRATION_REGISTRY
|
||||||
|
from .catalog import (
|
||||||
|
IntegrationCatalog,
|
||||||
|
IntegrationCatalogError,
|
||||||
|
IntegrationValidationError,
|
||||||
|
)
|
||||||
|
from .. import _require_specify_project
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
catalog = IntegrationCatalog(project_root)
|
||||||
|
installed_key = _default_integration_key(_read_integration_json(project_root))
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = catalog.get_integration_info(integration_id)
|
||||||
|
except IntegrationCatalogError as exc:
|
||||||
|
info = None
|
||||||
|
# Keep the live exception so the fallback branch below can give
|
||||||
|
# different guidance for local-config vs. network failures.
|
||||||
|
catalog_error: Optional[IntegrationCatalogError] = exc
|
||||||
|
else:
|
||||||
|
catalog_error = None
|
||||||
|
|
||||||
|
if info:
|
||||||
|
name = info.get("name", integration_id)
|
||||||
|
version = info.get("version", "?")
|
||||||
|
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
|
||||||
|
if info.get("description"):
|
||||||
|
console.print(f" {info['description']}")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
|
||||||
|
if info.get("license"):
|
||||||
|
console.print(f" [dim]License:[/dim] {info['license']}")
|
||||||
|
|
||||||
|
tags = info.get("tags", [])
|
||||||
|
if isinstance(tags, list) and tags:
|
||||||
|
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
|
||||||
|
|
||||||
|
cat_name = info.get("_catalog_name", "")
|
||||||
|
install_allowed = info.get("_install_allowed", True)
|
||||||
|
if cat_name:
|
||||||
|
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
|
||||||
|
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")
|
||||||
|
|
||||||
|
if info.get("repository"):
|
||||||
|
console.print(f" [dim]Repository:[/dim] {info['repository']}")
|
||||||
|
|
||||||
|
if integration_id == installed_key:
|
||||||
|
console.print("\n [green]✓ Installed[/green] (currently active)")
|
||||||
|
elif integration_id in INTEGRATION_REGISTRY:
|
||||||
|
console.print("\n [dim]Built-in integration (not currently active)[/dim]")
|
||||||
|
return
|
||||||
|
|
||||||
|
if integration_id in INTEGRATION_REGISTRY:
|
||||||
|
integration = INTEGRATION_REGISTRY[integration_id]
|
||||||
|
cfg = integration.config or {}
|
||||||
|
name = cfg.get("name", integration_id)
|
||||||
|
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id})")
|
||||||
|
console.print(" [dim]Built-in integration (not listed in catalog)[/dim]")
|
||||||
|
if integration_id == installed_key:
|
||||||
|
console.print("\n [green]✓ Installed[/green] (currently active)")
|
||||||
|
if catalog_error:
|
||||||
|
console.print(f"\n[yellow]Catalog unavailable:[/yellow] {catalog_error}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if catalog_error:
|
||||||
|
console.print(f"[red]Error:[/red] Could not query integration catalog: {catalog_error}")
|
||||||
|
if isinstance(catalog_error, IntegrationValidationError):
|
||||||
|
console.print(
|
||||||
|
"\nCheck the configuration file path shown above "
|
||||||
|
"(.specify/integration-catalogs.yml or ~/.specify/integration-catalogs.yml), "
|
||||||
|
"or use a built-in integration ID directly."
|
||||||
|
)
|
||||||
|
elif os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip():
|
||||||
|
console.print(
|
||||||
|
"\nCheck whether SPECKIT_INTEGRATION_CATALOG_URL is set correctly and reachable, "
|
||||||
|
"or unset it to use the configured catalog files, or use a built-in integration ID directly."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print("\nTry again when online, or use a built-in integration ID directly.")
|
||||||
|
else:
|
||||||
|
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
|
||||||
|
console.print("\nTry: specify integration search")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@integration_catalog_app.command("list")
|
||||||
|
def integration_catalog_list():
|
||||||
|
"""List configured integration catalog sources."""
|
||||||
|
from .catalog import IntegrationCatalog, IntegrationCatalogError
|
||||||
|
from .. import _require_specify_project
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
catalog = IntegrationCatalog(project_root)
|
||||||
|
env_override = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if env_override:
|
||||||
|
project_configs = None
|
||||||
|
configs = catalog.get_catalog_configs()
|
||||||
|
else:
|
||||||
|
project_configs = catalog.get_project_catalog_configs()
|
||||||
|
configs = project_configs if project_configs is not None else catalog.get_catalog_configs()
|
||||||
|
except IntegrationCatalogError as exc:
|
||||||
|
console.print(f"[red]Error:[/red] {exc}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print("\n[bold cyan]Integration Catalog Sources:[/bold cyan]\n")
|
||||||
|
if env_override:
|
||||||
|
console.print(
|
||||||
|
" SPECKIT_INTEGRATION_CATALOG_URL is set; it supersedes configured catalog files."
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
" Project/user catalog sources are not active while the env override is set.\n"
|
||||||
|
)
|
||||||
|
console.print("[bold]Active catalog source from environment (non-removable here):[/bold]\n")
|
||||||
|
elif project_configs is None:
|
||||||
|
console.print(" No project-level catalog sources configured.\n")
|
||||||
|
console.print("[bold]Active catalog sources (non-removable here):[/bold]\n")
|
||||||
|
else:
|
||||||
|
console.print("[bold]Project catalog sources (removable):[/bold]\n")
|
||||||
|
|
||||||
|
for i, cfg in enumerate(configs):
|
||||||
|
install_status = (
|
||||||
|
"[green]install allowed[/green]"
|
||||||
|
if cfg.get("install_allowed")
|
||||||
|
else "[yellow]discovery only[/yellow]"
|
||||||
|
)
|
||||||
|
raw_name = cfg.get("name")
|
||||||
|
display_name = str(raw_name).strip() if raw_name is not None else ""
|
||||||
|
if not display_name:
|
||||||
|
display_name = f"catalog-{i + 1}"
|
||||||
|
if env_override or project_configs is None:
|
||||||
|
console.print(f" - [bold]{display_name}[/bold] — {install_status}")
|
||||||
|
else:
|
||||||
|
console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}")
|
||||||
|
console.print(f" {cfg.get('url', '')}")
|
||||||
|
if cfg.get("description"):
|
||||||
|
console.print(f" [dim]{cfg['description']}[/dim]")
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
@integration_catalog_app.command("add")
|
||||||
|
def integration_catalog_add(
|
||||||
|
url: str = typer.Argument(
|
||||||
|
...,
|
||||||
|
help=(
|
||||||
|
"Catalog URL to add (HTTPS required, except http://localhost, "
|
||||||
|
"http://127.0.0.1, or http://[::1] for local testing)"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
name: Optional[str] = typer.Option(None, "--name", help="Catalog name"),
|
||||||
|
):
|
||||||
|
"""Add an integration catalog source to the project config."""
|
||||||
|
from .catalog import IntegrationCatalog, IntegrationCatalogError
|
||||||
|
from .. import _require_specify_project
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
catalog = IntegrationCatalog(project_root)
|
||||||
|
|
||||||
|
# Normalize once here so the success message reflects what was actually
|
||||||
|
# stored. ``IntegrationCatalog.add_catalog`` strips again defensively.
|
||||||
|
normalized_url = url.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
catalog.add_catalog(normalized_url, name)
|
||||||
|
except IntegrationCatalogError as exc:
|
||||||
|
# Covers both URL validation (base class) and config-file validation
|
||||||
|
# (IntegrationValidationError subclass).
|
||||||
|
console.print(f"[red]Error:[/red] {exc}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] Catalog source added: {normalized_url}")
|
||||||
|
|
||||||
|
|
||||||
|
@integration_catalog_app.command("remove")
|
||||||
|
def integration_catalog_remove(
|
||||||
|
index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"),
|
||||||
|
):
|
||||||
|
"""Remove an integration catalog source by 0-based index."""
|
||||||
|
from .catalog import IntegrationCatalog, IntegrationCatalogError
|
||||||
|
from .. import _require_specify_project
|
||||||
|
|
||||||
|
project_root = _require_specify_project()
|
||||||
|
catalog = IntegrationCatalog(project_root)
|
||||||
|
|
||||||
|
try:
|
||||||
|
removed_name = catalog.remove_catalog(index)
|
||||||
|
except IntegrationCatalogError as exc:
|
||||||
|
console.print(f"[red]Error:[/red] {exc}")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed")
|
||||||
162
src/specify_cli/integrations/cline/__init__.py
Normal file
162
src/specify_cli/integrations/cline/__init__.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""Cline IDE integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..base import MarkdownIntegration
|
||||||
|
from ..manifest import IntegrationManifest
|
||||||
|
|
||||||
|
|
||||||
|
# Note injected into hook sections so Cline maps dot-notation command
|
||||||
|
# names (from extensions.yml) to the hyphenated slash commands it uses.
|
||||||
|
_HOOK_COMMAND_NOTE = (
|
||||||
|
"- When constructing slash commands from hook command names, "
|
||||||
|
"replace dots (`.`) with hyphens (`-`). "
|
||||||
|
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_cline_command_name(cmd_name: str) -> str:
|
||||||
|
"""Convert command name to Cline-compatible hyphenated format.
|
||||||
|
|
||||||
|
Cline handles slash-commands optimally when they use hyphens instead of dots.
|
||||||
|
This function converts dot-notation command names to hyphenated format.
|
||||||
|
|
||||||
|
The function is idempotent: already-formatted names are returned unchanged.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> format_cline_command_name("plan")
|
||||||
|
'speckit-plan'
|
||||||
|
>>> format_cline_command_name("speckit.plan")
|
||||||
|
'speckit-plan'
|
||||||
|
>>> format_cline_command_name("speckit.git.commit")
|
||||||
|
'speckit-git-commit'
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd_name: Command name in dot notation (speckit.foo.bar),
|
||||||
|
hyphenated format (speckit-foo-bar), or plain name (foo)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hyphenated command name with 'speckit-' prefix
|
||||||
|
"""
|
||||||
|
cmd_name = cmd_name.replace(".", "-")
|
||||||
|
|
||||||
|
if not cmd_name.startswith("speckit-"):
|
||||||
|
cmd_name = f"speckit-{cmd_name}"
|
||||||
|
|
||||||
|
return cmd_name
|
||||||
|
|
||||||
|
|
||||||
|
class ClineIntegration(MarkdownIntegration):
|
||||||
|
"""Integration for Cline IDE."""
|
||||||
|
|
||||||
|
key = "cline"
|
||||||
|
config = {
|
||||||
|
"name": "Cline",
|
||||||
|
"folder": ".clinerules/",
|
||||||
|
"commands_subdir": "workflows",
|
||||||
|
"install_url": "https://github.com/cline/cline",
|
||||||
|
"requires_cli": False,
|
||||||
|
}
|
||||||
|
registrar_config = {
|
||||||
|
"dir": ".clinerules/workflows",
|
||||||
|
"format": "markdown",
|
||||||
|
"args": "$ARGUMENTS",
|
||||||
|
"extension": ".md",
|
||||||
|
"inject_name": True,
|
||||||
|
"format_name": format_cline_command_name,
|
||||||
|
"invoke_separator": "-",
|
||||||
|
}
|
||||||
|
context_file = ".clinerules/specify-rules.md"
|
||||||
|
invoke_separator = "-"
|
||||||
|
multi_install_safe = True
|
||||||
|
|
||||||
|
def command_filename(self, template_name: str) -> str:
|
||||||
|
"""Cline uses hyphenated filenames (e.g. speckit-git-commit.md)."""
|
||||||
|
return format_cline_command_name(template_name) + ".md"
|
||||||
|
|
||||||
|
def process_template(self, *args, **kwargs):
|
||||||
|
"""Ensure shared templates render Cline command references with hyphens."""
|
||||||
|
kwargs.setdefault("invoke_separator", self.invoke_separator)
|
||||||
|
return super().process_template(*args, **kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _inject_hook_command_note(content: str) -> str:
|
||||||
|
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||||
|
|
||||||
|
Targets the line ``- For each executable hook, output the following``
|
||||||
|
and inserts the note on the line before it, matching its indentation.
|
||||||
|
Skips if the note is already present.
|
||||||
|
"""
|
||||||
|
if "replace dots" in content:
|
||||||
|
return content
|
||||||
|
|
||||||
|
def repl(m: re.Match[str]) -> str:
|
||||||
|
indent = m.group(1)
|
||||||
|
instruction = m.group(2)
|
||||||
|
eol = m.group(3)
|
||||||
|
return (
|
||||||
|
indent
|
||||||
|
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||||
|
+ eol
|
||||||
|
+ indent
|
||||||
|
+ instruction
|
||||||
|
+ eol
|
||||||
|
)
|
||||||
|
|
||||||
|
return re.sub(
|
||||||
|
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||||
|
repl,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rewrite_handoff_references(content: str) -> str:
|
||||||
|
"""Replace dot-notation agent references in handoffs with hyphens."""
|
||||||
|
return re.sub(
|
||||||
|
r"(?m)^(\s*agent:\s*)(speckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*)",
|
||||||
|
lambda m: f"{m.group(1)}{format_cline_command_name(m.group(2))}",
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
|
||||||
|
def post_process_content(self, content: str) -> str:
|
||||||
|
"""Apply Cline-specific transformations to command content."""
|
||||||
|
updated = self._inject_hook_command_note(content)
|
||||||
|
updated = self._rewrite_handoff_references(updated)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def setup(
|
||||||
|
self,
|
||||||
|
project_root: Path,
|
||||||
|
manifest: IntegrationManifest,
|
||||||
|
parsed_options: dict[str, Any] | None = None,
|
||||||
|
**opts: Any,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""Install Cline commands and apply post-processing transformations."""
|
||||||
|
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||||
|
|
||||||
|
# Post-process generated command files
|
||||||
|
dest_dir = self.commands_dest(project_root).resolve()
|
||||||
|
|
||||||
|
for path in created:
|
||||||
|
# Only touch .md files under the commands directory
|
||||||
|
try:
|
||||||
|
path.resolve().relative_to(dest_dir)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if path.suffix != ".md":
|
||||||
|
continue
|
||||||
|
|
||||||
|
content_bytes = path.read_bytes()
|
||||||
|
content = content_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
updated = self.post_process_content(content)
|
||||||
|
|
||||||
|
if updated != content:
|
||||||
|
path.write_bytes(updated.encode("utf-8"))
|
||||||
|
self.record_file_in_manifest(path, project_root, manifest)
|
||||||
|
|
||||||
|
return created
|
||||||
@@ -283,58 +283,13 @@ class CopilotIntegration(IntegrationBase):
|
|||||||
return f"speckit.{template_name}.agent.md"
|
return f"speckit.{template_name}.agent.md"
|
||||||
|
|
||||||
def post_process_skill_content(self, content: str) -> str:
|
def post_process_skill_content(self, content: str) -> str:
|
||||||
"""Inject shared hook guidance and Copilot ``mode:`` frontmatter.
|
"""Inject shared hook guidance into Copilot skill content.
|
||||||
|
|
||||||
Inserts ``mode: speckit.<stem>`` before the closing ``---`` so
|
Delegates to :class:`_CopilotSkillsHelper` for shared post-processing.
|
||||||
Copilot can associate the skill with its agent mode.
|
The ``mode:`` frontmatter field is intentionally omitted: VS Code
|
||||||
|
Copilot Agent Skills do not support it (see issue #2799).
|
||||||
"""
|
"""
|
||||||
updated = _CopilotSkillsHelper().post_process_skill_content(content)
|
return _CopilotSkillsHelper().post_process_skill_content(content)
|
||||||
lines = updated.splitlines(keepends=True)
|
|
||||||
|
|
||||||
# Extract skill name from frontmatter to derive the mode value
|
|
||||||
dash_count = 0
|
|
||||||
skill_name = ""
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.rstrip("\n\r")
|
|
||||||
if stripped == "---":
|
|
||||||
dash_count += 1
|
|
||||||
if dash_count == 2:
|
|
||||||
break
|
|
||||||
continue
|
|
||||||
if dash_count == 1:
|
|
||||||
if stripped.startswith("mode:"):
|
|
||||||
return updated # already present
|
|
||||||
if stripped.startswith("name:"):
|
|
||||||
# Parse: name: "speckit-plan" → speckit.plan
|
|
||||||
val = stripped.split(":", 1)[1].strip().strip('"').strip("'")
|
|
||||||
# Convert speckit-plan → speckit.plan
|
|
||||||
if val.startswith("speckit-"):
|
|
||||||
skill_name = "speckit." + val[len("speckit-"):]
|
|
||||||
else:
|
|
||||||
skill_name = val
|
|
||||||
|
|
||||||
if not skill_name:
|
|
||||||
return updated
|
|
||||||
|
|
||||||
# Inject mode: before the closing --- of frontmatter
|
|
||||||
out: list[str] = []
|
|
||||||
dash_count = 0
|
|
||||||
injected = False
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.rstrip("\n\r")
|
|
||||||
if stripped == "---":
|
|
||||||
dash_count += 1
|
|
||||||
if dash_count == 2 and not injected:
|
|
||||||
if line.endswith("\r\n"):
|
|
||||||
eol = "\r\n"
|
|
||||||
elif line.endswith("\n"):
|
|
||||||
eol = "\n"
|
|
||||||
else:
|
|
||||||
eol = ""
|
|
||||||
out.append(f"mode: {skill_name}{eol}")
|
|
||||||
injected = True
|
|
||||||
out.append(line)
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
def setup(
|
def setup(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class IntegrationManifest:
|
|||||||
self.project_root = project_root.resolve()
|
self.project_root = project_root.resolve()
|
||||||
self.version = version
|
self.version = version
|
||||||
self._files: dict[str, str] = {} # rel_path → sha256 hex
|
self._files: dict[str, str] = {} # rel_path → sha256 hex
|
||||||
|
self._recovered_files: set[str] = set()
|
||||||
self._installed_at: str = ""
|
self._installed_at: str = ""
|
||||||
|
|
||||||
# -- Manifest file location -------------------------------------------
|
# -- Manifest file location -------------------------------------------
|
||||||
@@ -131,6 +132,9 @@ class IntegrationManifest:
|
|||||||
|
|
||||||
Creates parent directories as needed. Returns the absolute path
|
Creates parent directories as needed. Returns the absolute path
|
||||||
of the written file.
|
of the written file.
|
||||||
|
If the path was previously marked as recovered via
|
||||||
|
``record_existing(recovered=True)``, the recovered marker is
|
||||||
|
cleared because the bytes are now produced, not merely observed.
|
||||||
|
|
||||||
Raises ``ValueError`` if *rel_path* resolves outside the project root.
|
Raises ``ValueError`` if *rel_path* resolves outside the project root.
|
||||||
"""
|
"""
|
||||||
@@ -144,17 +148,77 @@ class IntegrationManifest:
|
|||||||
|
|
||||||
normalized = abs_path.relative_to(self.project_root).as_posix()
|
normalized = abs_path.relative_to(self.project_root).as_posix()
|
||||||
self._files[normalized] = hashlib.sha256(content).hexdigest()
|
self._files[normalized] = hashlib.sha256(content).hexdigest()
|
||||||
|
# ``record_file`` writes *produced* content, so any prior
|
||||||
|
# recovered marker for this path is no longer accurate.
|
||||||
|
self._recovered_files.discard(normalized)
|
||||||
return abs_path
|
return abs_path
|
||||||
|
|
||||||
def record_existing(self, rel_path: str | Path) -> None:
|
def record_existing(self, rel_path: str | Path, *, recovered: bool = False) -> None:
|
||||||
"""Record the hash of an already-existing file at *rel_path*.
|
"""Record the hash of an already-existing regular file at *rel_path*.
|
||||||
|
|
||||||
Raises ``ValueError`` if *rel_path* resolves outside the project root.
|
When ``recovered=True``, the path is also marked in the manifest's
|
||||||
|
``recovered_files`` list to signal that the file's on-disk hash was
|
||||||
|
*observed* during install (because the file already existed and was not
|
||||||
|
overwritten), not *produced* by the install. Future ``refresh_managed``
|
||||||
|
runs should consult ``is_recovered`` before treating the recorded hash
|
||||||
|
as a managed baseline.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if *rel_path* resolves outside the project root, is
|
||||||
|
a symlink, or is not a regular file. A directory or other
|
||||||
|
non-file path cannot be silently recorded — its hash would
|
||||||
|
be meaningless and ``check_modified``/``uninstall`` would
|
||||||
|
treat the entry as permanently broken.
|
||||||
|
OSError: if the underlying filesystem call (``is_symlink``,
|
||||||
|
``is_file``, or the file-read used to compute the hash)
|
||||||
|
fails — for example a ``PermissionError`` on the path.
|
||||||
|
Callers should be prepared to handle ``OSError`` (and its
|
||||||
|
subclasses such as ``PermissionError``) in addition to
|
||||||
|
``ValueError``.
|
||||||
"""
|
"""
|
||||||
rel = Path(rel_path)
|
rel = Path(rel_path)
|
||||||
|
# Cheap lexical pre-check first so absolute / parent-traversal paths
|
||||||
|
# don't trigger a filesystem stat outside the project root before
|
||||||
|
# ``_validate_rel_path`` raises. ``_validate_rel_path`` produces the
|
||||||
|
# canonical error messages used elsewhere.
|
||||||
|
if rel.is_absolute() or ".." in rel.parts:
|
||||||
|
_validate_rel_path(rel, self.project_root)
|
||||||
|
# _validate_rel_path raised for any actually-escaping path. If we reach
|
||||||
|
# here the path normalizes inside root (e.g. ``dir/../file.txt``).
|
||||||
|
# Reject anyway: manifest keys must be canonical so ``check_modified``
|
||||||
|
# and ``uninstall`` cannot key the same file under two paths.
|
||||||
|
raise ValueError(
|
||||||
|
f"Manifest paths must be canonical; '..' segments are not "
|
||||||
|
f"allowed (got {rel})"
|
||||||
|
)
|
||||||
|
# Walk each path component before resolution so a symlinked ancestor
|
||||||
|
# (e.g. ``linked_dir/file.txt`` where ``linked_dir`` is a symlink)
|
||||||
|
# cannot be silently followed by ``_validate_rel_path().resolve()``
|
||||||
|
# down to a target outside the project root. ``_ensure_safe_manifest_directory``
|
||||||
|
# uses the same pattern.
|
||||||
|
_walk = self.project_root
|
||||||
|
for part in rel.parts:
|
||||||
|
_walk = _walk / part
|
||||||
|
if _walk.is_symlink():
|
||||||
|
raise ValueError(
|
||||||
|
f"Refusing to record symlinked manifest path: {rel} "
|
||||||
|
f"(symlinked at {_walk.relative_to(self.project_root).as_posix()})"
|
||||||
|
)
|
||||||
abs_path = _validate_rel_path(rel, self.project_root)
|
abs_path = _validate_rel_path(rel, self.project_root)
|
||||||
|
if not abs_path.is_file():
|
||||||
|
raise ValueError(
|
||||||
|
f"Manifest path is not a regular file: {rel}"
|
||||||
|
)
|
||||||
normalized = abs_path.relative_to(self.project_root).as_posix()
|
normalized = abs_path.relative_to(self.project_root).as_posix()
|
||||||
self._files[normalized] = _sha256(abs_path)
|
self._files[normalized] = _sha256(abs_path)
|
||||||
|
if recovered:
|
||||||
|
self._recovered_files.add(normalized)
|
||||||
|
else:
|
||||||
|
# ``recovered=False`` means the caller is asserting this path is
|
||||||
|
# managed-baseline now, not merely observed; drop any stale
|
||||||
|
# recovered marker so future is_recovered() queries reflect the
|
||||||
|
# transition. ``discard`` is a no-op when the key is absent.
|
||||||
|
self._recovered_files.discard(normalized)
|
||||||
|
|
||||||
# -- Querying ---------------------------------------------------------
|
# -- Querying ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -163,6 +227,37 @@ class IntegrationManifest:
|
|||||||
"""Return a copy of the ``{rel_path: sha256}`` mapping."""
|
"""Return a copy of the ``{rel_path: sha256}`` mapping."""
|
||||||
return dict(self._files)
|
return dict(self._files)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recovered_files(self) -> set[str]:
|
||||||
|
"""Return a copy of the set of paths recorded with ``recovered=True``.
|
||||||
|
|
||||||
|
These entries had their hashes observed (not produced) during install
|
||||||
|
because the file already existed on disk and the install skipped it.
|
||||||
|
Their on-disk bytes may be user customizations — callers that would
|
||||||
|
overwrite based on hash equality (e.g. ``refresh_managed``) MUST check
|
||||||
|
``is_recovered`` first.
|
||||||
|
"""
|
||||||
|
return set(self._recovered_files)
|
||||||
|
|
||||||
|
def is_recovered(self, rel_path: str | Path) -> bool:
|
||||||
|
"""Return True if *rel_path* was recorded via ``record_existing(recovered=True)``.
|
||||||
|
|
||||||
|
Input is normalized through the same pipeline as ``record_existing``:
|
||||||
|
absolute paths, paths escaping the project root, AND paths containing
|
||||||
|
``'..'`` segments are rejected (returned as ``False``). This mirrors
|
||||||
|
``record_existing``'s canonicalization guard — such paths can never
|
||||||
|
appear as stored keys, so the answer is always ``False``.
|
||||||
|
"""
|
||||||
|
rel = Path(rel_path)
|
||||||
|
if rel.is_absolute() or ".." in rel.parts:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
abs_path = _validate_rel_path(rel, self.project_root)
|
||||||
|
normalized = abs_path.relative_to(self.project_root).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return normalized in self._recovered_files
|
||||||
|
|
||||||
def check_modified(self) -> list[str]:
|
def check_modified(self) -> list[str]:
|
||||||
"""Return relative paths of tracked files whose content changed on disk."""
|
"""Return relative paths of tracked files whose content changed on disk."""
|
||||||
modified: list[str] = []
|
modified: list[str] = []
|
||||||
@@ -269,6 +364,11 @@ class IntegrationManifest:
|
|||||||
"version": self.version,
|
"version": self.version,
|
||||||
"installed_at": self._installed_at,
|
"installed_at": self._installed_at,
|
||||||
"files": self._files,
|
"files": self._files,
|
||||||
|
**(
|
||||||
|
{"recovered_files": sorted(self._recovered_files)}
|
||||||
|
if self._recovered_files
|
||||||
|
else {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
path = self.manifest_path
|
path = self.manifest_path
|
||||||
content = json.dumps(data, indent=2) + "\n"
|
content = json.dumps(data, indent=2) + "\n"
|
||||||
@@ -320,6 +420,20 @@ class IntegrationManifest:
|
|||||||
inst._installed_at = data.get("installed_at", "")
|
inst._installed_at = data.get("installed_at", "")
|
||||||
inst._files = files
|
inst._files = files
|
||||||
|
|
||||||
|
recovered = data.get("recovered_files", [])
|
||||||
|
if not isinstance(recovered, list) or not all(
|
||||||
|
isinstance(p, str) for p in recovered
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Integration manifest 'recovered_files' at {path} must be a "
|
||||||
|
"list of string paths"
|
||||||
|
)
|
||||||
|
inst._recovered_files = set(recovered)
|
||||||
|
# Drop any recovered_files entries that don't correspond to tracked
|
||||||
|
# files — defensive against externally-edited or partially-corrupted
|
||||||
|
# manifests. Inconsistent state self-corrects on next save().
|
||||||
|
inst._recovered_files &= set(inst._files.keys())
|
||||||
|
|
||||||
stored_key = data.get("integration", "")
|
stored_key = data.get("integration", "")
|
||||||
if stored_key and stored_key != key:
|
if stored_key and stored_key != key:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|||||||
@@ -365,6 +365,23 @@ def install_shared_infra(
|
|||||||
preserved_user_files.append(rel)
|
preserved_user_files.append(rel)
|
||||||
else:
|
else:
|
||||||
skipped_files.append(rel)
|
skipped_files.append(rel)
|
||||||
|
# Record the existing-on-disk file in the manifest so a
|
||||||
|
# fresh manifest run against an already-populated
|
||||||
|
# ``.specify/`` tree does not silently drop it (#2107).
|
||||||
|
# ``prior_hashes`` is the function-scope snapshot taken
|
||||||
|
# at entry, so this membership check is O(1) and avoids
|
||||||
|
# the repeated ``dict(self._files)`` copy that
|
||||||
|
# ``manifest.files`` performs on every access.
|
||||||
|
if dst_path.is_file() and rel not in prior_hashes:
|
||||||
|
try:
|
||||||
|
manifest.record_existing(rel, recovered=True)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
# Tolerate races / permission issues / non-file
|
||||||
|
# collisions so one weird path does not abort
|
||||||
|
# the whole install.
|
||||||
|
console.print(
|
||||||
|
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||||
@@ -398,6 +415,23 @@ def install_shared_infra(
|
|||||||
preserved_user_files.append(rel)
|
preserved_user_files.append(rel)
|
||||||
else:
|
else:
|
||||||
skipped_files.append(rel)
|
skipped_files.append(rel)
|
||||||
|
# Record the existing-on-disk template in the manifest so a
|
||||||
|
# fresh manifest run against an already-populated
|
||||||
|
# ``.specify/`` tree does not silently drop it (#2107).
|
||||||
|
# ``prior_hashes`` is the function-scope snapshot taken at
|
||||||
|
# entry, so this membership check is O(1) and avoids the
|
||||||
|
# repeated ``dict(self._files)`` copy that ``manifest.files``
|
||||||
|
# performs on every access.
|
||||||
|
if dst.is_file() and rel not in prior_hashes:
|
||||||
|
try:
|
||||||
|
manifest.record_existing(rel, recovered=True)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
# Tolerate races / permission issues / non-file
|
||||||
|
# collisions so one weird path does not abort
|
||||||
|
# the whole install.
|
||||||
|
console.print(
|
||||||
|
f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
content = src.read_text(encoding="utf-8")
|
content = src.read_text(encoding="utf-8")
|
||||||
@@ -416,7 +450,7 @@ def install_shared_infra(
|
|||||||
|
|
||||||
if skipped_files:
|
if skipped_files:
|
||||||
console.print(
|
console.print(
|
||||||
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
|
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure path(s) already exist and were not updated:"
|
||||||
)
|
)
|
||||||
for path in skipped_files:
|
for path in skipped_files:
|
||||||
console.print(f" {path}")
|
console.print(f" {path}")
|
||||||
|
|||||||
@@ -232,6 +232,22 @@ def _validate_steps(
|
|||||||
step_errors = step_impl.validate(step_config)
|
step_errors = step_impl.validate(step_config)
|
||||||
errors.extend(step_errors)
|
errors.extend(step_errors)
|
||||||
|
|
||||||
|
# Validate optional `continue_on_error` field. The engine honours
|
||||||
|
# this on any step that returns StepStatus.FAILED so the pipeline can route
|
||||||
|
# around the failure via a downstream `if` or `switch` (or a
|
||||||
|
# `gate` that surfaces the failure to the operator via message
|
||||||
|
# interpolation). The field must be a literal boolean —
|
||||||
|
# coercion from truthy strings is deliberately not supported so
|
||||||
|
# authoring mistakes surface at validation time rather than
|
||||||
|
# silently changing run semantics.
|
||||||
|
if "continue_on_error" in step_config:
|
||||||
|
coe = step_config["continue_on_error"]
|
||||||
|
if not isinstance(coe, bool):
|
||||||
|
errors.append(
|
||||||
|
f"Step {step_id!r}: 'continue_on_error' must be a "
|
||||||
|
f"boolean, got {type(coe).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
# Recursively validate nested steps
|
# Recursively validate nested steps
|
||||||
for nested_key in ("then", "else", "steps"):
|
for nested_key in ("then", "else", "steps"):
|
||||||
nested = step_config.get(nested_key)
|
nested = step_config.get(nested_key)
|
||||||
@@ -629,7 +645,10 @@ class WorkflowEngine:
|
|||||||
|
|
||||||
# Handle failures
|
# Handle failures
|
||||||
if result.status == StepStatus.FAILED:
|
if result.status == StepStatus.FAILED:
|
||||||
# Gate abort (output.aborted) maps to ABORTED status
|
# Gate abort (output.aborted) maps to ABORTED status.
|
||||||
|
# Aborts are deliberate operator decisions, so
|
||||||
|
# `continue_on_error` does NOT override them — that flag
|
||||||
|
# is for transient/expected step failures only.
|
||||||
if result.output.get("aborted"):
|
if result.output.get("aborted"):
|
||||||
state.status = RunStatus.ABORTED
|
state.status = RunStatus.ABORTED
|
||||||
state.append_log(
|
state.append_log(
|
||||||
@@ -638,15 +657,49 @@ class WorkflowEngine:
|
|||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
else:
|
state.save()
|
||||||
state.status = RunStatus.FAILED
|
return
|
||||||
|
|
||||||
|
# `continue_on_error: true` lets the pipeline route
|
||||||
|
# around the failure instead of halting. The step
|
||||||
|
# result (including exit_code, stderr, status) is
|
||||||
|
# still recorded so a downstream `if` or `switch`
|
||||||
|
# can branch on it (or a `gate` can surface it to the
|
||||||
|
# operator via message interpolation). Log a single,
|
||||||
|
# unambiguous event per failure resolution — either
|
||||||
|
# the run continued past it, or it halted.
|
||||||
|
#
|
||||||
|
# Use identity comparison (`is True`) rather than
|
||||||
|
# truthiness so that only a literal boolean enables
|
||||||
|
# the behaviour, even if validation was skipped.
|
||||||
|
# Validation rejects non-bool values at parse time,
|
||||||
|
# but `WorkflowEngine.execute()` does not auto-validate
|
||||||
|
# (see `WorkflowEngine.load_workflow`, whose docstring
|
||||||
|
# explicitly notes "not yet validated; call
|
||||||
|
# `validate_workflow()` or `engine.validate()`
|
||||||
|
# separately"), so a caller passing an unvalidated
|
||||||
|
# definition could otherwise see truthy non-bool
|
||||||
|
# values like the string `"true"` silently change
|
||||||
|
# run semantics.
|
||||||
|
if step_config.get("continue_on_error") is True:
|
||||||
state.append_log(
|
state.append_log(
|
||||||
{
|
{
|
||||||
"event": "step_failed",
|
"event": "step_continue_on_error",
|
||||||
"step_id": step_id,
|
"step_id": step_id,
|
||||||
"error": result.error,
|
"error": result.error,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
state.save()
|
||||||
|
continue
|
||||||
|
|
||||||
|
state.status = RunStatus.FAILED
|
||||||
|
state.append_log(
|
||||||
|
{
|
||||||
|
"event": "step_failed",
|
||||||
|
"step_id": step_id,
|
||||||
|
"error": result.error,
|
||||||
|
}
|
||||||
|
)
|
||||||
state.save()
|
state.save()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ Given that feature description, do this:
|
|||||||
|
|
||||||
**Create the directory and spec file**:
|
**Create the directory and spec file**:
|
||||||
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||||
- Copy `templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
|
- Resolve the active `spec-template` through the Spec Kit preset/template resolution stack (equivalent to `specify preset resolve spec-template`)
|
||||||
|
- Copy the resolved `spec-template` file to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
|
||||||
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
|
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
|
||||||
- Persist the resolved path to `.specify/feature.json`:
|
- Persist the resolved path to `.specify/feature.json`:
|
||||||
```json
|
```json
|
||||||
@@ -107,7 +108,7 @@ Given that feature description, do this:
|
|||||||
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
||||||
- The spec directory and file are always created by this command, never by the hook
|
- The spec directory and file are always created by this command, never by the hook
|
||||||
|
|
||||||
4. Load `templates/spec-template.md` to understand required sections.
|
4. Load the resolved active `spec-template` file to understand required sections.
|
||||||
|
|
||||||
5. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
5. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||||
|
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ class TestInitIntegrationFlag:
|
|||||||
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
|
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
|
||||||
"""Console warning is displayed when files are skipped."""
|
"""Console warning is displayed when files are skipped."""
|
||||||
from specify_cli import _install_shared_infra
|
from specify_cli import _install_shared_infra
|
||||||
|
from tests.conftest import strip_ansi
|
||||||
|
|
||||||
project = tmp_path / "warn-test"
|
project = tmp_path / "warn-test"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import codecs
|
import codecs
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
@@ -577,3 +578,204 @@ class TestClaudeHookCommandNote:
|
|||||||
assert "user-invocable: true" in result
|
assert "user-invocable: true" in result
|
||||||
assert "disable-model-invocation: false" in result
|
assert "disable-model-invocation: false" in result
|
||||||
assert "replace dots" in result
|
assert "replace dots" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpeckitManifestRecordsSkippedFiles:
|
||||||
|
"""Regression test for issue #2107.
|
||||||
|
|
||||||
|
``install_shared_infra`` must record every shared-infrastructure file
|
||||||
|
under ``.specify/`` in ``speckit.manifest.json``, including files that
|
||||||
|
were *skipped* because they already existed on disk and ``force=False``.
|
||||||
|
|
||||||
|
Before the fix, the skip branches in the scripts and templates loops
|
||||||
|
appended to ``skipped_files`` without calling ``manifest.record_existing``.
|
||||||
|
So when ``install_shared_infra`` ran with a fresh (or lost) manifest
|
||||||
|
against an already-populated ``.specify/`` tree, every file went down the
|
||||||
|
skip path, ``planned_copies`` and ``planned_templates`` stayed empty, and
|
||||||
|
``manifest.save()`` wrote an empty ``files`` field — leaving the
|
||||||
|
integration believing nothing was installed.
|
||||||
|
|
||||||
|
Reproduction (without the fix) using ``install_shared_infra`` directly:
|
||||||
|
|
||||||
|
install_shared_infra(p, "sh", ..., force=False) # 1st run → 10 files
|
||||||
|
(p / ".specify/integrations/speckit.manifest.json").unlink()
|
||||||
|
install_shared_infra(p, "sh", ..., force=False) # 2nd run → 0 files
|
||||||
|
# ^^ BUG: empty
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _read_manifest_files(self, project_path: Path) -> dict:
|
||||||
|
manifest_path = (
|
||||||
|
project_path / ".specify" / "integrations" / "speckit.manifest.json"
|
||||||
|
)
|
||||||
|
assert manifest_path.exists(), (
|
||||||
|
f"speckit.manifest.json not written at {manifest_path}"
|
||||||
|
)
|
||||||
|
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
# ``IntegrationManifest.save`` serialises a ``files`` dict — assert
|
||||||
|
# the schema explicitly so a regression to a different key (e.g.
|
||||||
|
# the internal ``_files`` attribute name) fails loudly instead of
|
||||||
|
# being masked by a silent fallback.
|
||||||
|
assert isinstance(data, dict), (
|
||||||
|
f"manifest root is not a dict, got {type(data).__name__}"
|
||||||
|
)
|
||||||
|
assert "files" in data, (
|
||||||
|
f"manifest missing 'files' key, got keys: {sorted(data.keys())}"
|
||||||
|
)
|
||||||
|
files = data["files"]
|
||||||
|
assert isinstance(files, dict), (
|
||||||
|
f"manifest 'files' is not a dict, got {type(files).__name__}"
|
||||||
|
)
|
||||||
|
return files
|
||||||
|
|
||||||
|
def test_install_shared_infra_records_skipped_files(self, tmp_path):
|
||||||
|
"""With ``force=False`` and ``.specify/`` already populated, the
|
||||||
|
manifest must still record every file — the skip branches are not
|
||||||
|
allowed to drop files from the manifest."""
|
||||||
|
from rich.console import Console
|
||||||
|
from specify_cli.shared_infra import install_shared_infra
|
||||||
|
|
||||||
|
# Resolve the project's own packaged sources by walking up from this
|
||||||
|
# test file to the repo root (which contains ``scripts/`` and
|
||||||
|
# ``templates/`` that ``shared_scripts_source`` looks for).
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
console = Console(quiet=True)
|
||||||
|
|
||||||
|
# First run — fresh project, manifest gets populated normally.
|
||||||
|
install_shared_infra(
|
||||||
|
tmp_path,
|
||||||
|
"sh",
|
||||||
|
version="0.0.0",
|
||||||
|
core_pack=None,
|
||||||
|
repo_root=repo_root,
|
||||||
|
console=console,
|
||||||
|
force=False,
|
||||||
|
)
|
||||||
|
first_files = self._read_manifest_files(tmp_path)
|
||||||
|
assert first_files, "first install produced an empty manifest"
|
||||||
|
|
||||||
|
# Simulate a lost manifest while ``.specify/`` is still on disk
|
||||||
|
# (e.g. the manifest was deleted, corrupted, or the layout was
|
||||||
|
# extracted out-of-band).
|
||||||
|
manifest_path = (
|
||||||
|
tmp_path / ".specify" / "integrations" / "speckit.manifest.json"
|
||||||
|
)
|
||||||
|
manifest_path.unlink()
|
||||||
|
|
||||||
|
# Second run — every file already exists, so every iteration takes
|
||||||
|
# the skip branch. With the fix, those files are still recorded.
|
||||||
|
install_shared_infra(
|
||||||
|
tmp_path,
|
||||||
|
"sh",
|
||||||
|
version="0.0.0",
|
||||||
|
core_pack=None,
|
||||||
|
repo_root=repo_root,
|
||||||
|
console=console,
|
||||||
|
force=False,
|
||||||
|
)
|
||||||
|
second_files = self._read_manifest_files(tmp_path)
|
||||||
|
assert second_files, (
|
||||||
|
"speckit.manifest.json files dict is empty after install with "
|
||||||
|
"skipped files (issue #2107) — every file went down the skip "
|
||||||
|
"branch but none were recorded"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The recovered manifest must cover everything the first run tracked.
|
||||||
|
missing = set(first_files) - set(second_files)
|
||||||
|
assert not missing, (
|
||||||
|
f"these files were tracked on the first install but missing after "
|
||||||
|
f"the skipped-files re-install: {sorted(missing)[:5]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_install_shared_infra_handles_directory_at_script_destination(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
"""A non-file (directory) at a script's destination must NOT crash
|
||||||
|
``install_shared_infra`` and must NOT be recorded in the manifest —
|
||||||
|
the path still appears in the user-visible skipped-paths warning.
|
||||||
|
"""
|
||||||
|
from io import StringIO
|
||||||
|
from rich.console import Console
|
||||||
|
from specify_cli.shared_infra import install_shared_infra
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
output = StringIO()
|
||||||
|
console = Console(file=output, force_terminal=False, width=200)
|
||||||
|
|
||||||
|
# Pre-create the .specify/scripts/bash tree, then plant a directory
|
||||||
|
# where a script file is expected so the skip branch hits a
|
||||||
|
# non-regular-file path.
|
||||||
|
bash_dir = tmp_path / ".specify" / "scripts" / "bash"
|
||||||
|
bash_dir.mkdir(parents=True)
|
||||||
|
(bash_dir / "common.sh").mkdir() # collision: dir where file expected
|
||||||
|
|
||||||
|
# Must not crash.
|
||||||
|
install_shared_infra(
|
||||||
|
tmp_path,
|
||||||
|
"sh",
|
||||||
|
version="0.0.0",
|
||||||
|
core_pack=None,
|
||||||
|
repo_root=repo_root,
|
||||||
|
console=console,
|
||||||
|
force=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
files = self._read_manifest_files(tmp_path)
|
||||||
|
assert ".specify/scripts/bash/common.sh" not in files, (
|
||||||
|
"directory at script dst must not be recorded in the manifest"
|
||||||
|
)
|
||||||
|
text = output.getvalue()
|
||||||
|
assert "common.sh" in text, (
|
||||||
|
"directory-at-script-dst path must surface in the skipped warning"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_install_shared_infra_handles_directory_at_template_destination(
|
||||||
|
self, tmp_path
|
||||||
|
):
|
||||||
|
"""Symmetric coverage for the templates loop: a directory at a
|
||||||
|
template's destination must NOT crash install nor be recorded."""
|
||||||
|
from io import StringIO
|
||||||
|
from rich.console import Console
|
||||||
|
from specify_cli.shared_infra import install_shared_infra
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parents[2]
|
||||||
|
output = StringIO()
|
||||||
|
console = Console(file=output, force_terminal=False, width=200)
|
||||||
|
|
||||||
|
templates_dir = tmp_path / ".specify" / "templates"
|
||||||
|
templates_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
src_templates = repo_root / "templates"
|
||||||
|
real_template = next(
|
||||||
|
(
|
||||||
|
p.name
|
||||||
|
for p in src_templates.iterdir()
|
||||||
|
if p.is_file()
|
||||||
|
and not p.name.startswith(".")
|
||||||
|
and p.name != "vscode-settings.json"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert real_template, (
|
||||||
|
"no real template found in repo to collide against"
|
||||||
|
)
|
||||||
|
(templates_dir / real_template).mkdir() # collision
|
||||||
|
|
||||||
|
install_shared_infra(
|
||||||
|
tmp_path,
|
||||||
|
"sh",
|
||||||
|
version="0.0.0",
|
||||||
|
core_pack=None,
|
||||||
|
repo_root=repo_root,
|
||||||
|
console=console,
|
||||||
|
force=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
files = self._read_manifest_files(tmp_path)
|
||||||
|
template_rel = f".specify/templates/{real_template}"
|
||||||
|
assert template_rel not in files, (
|
||||||
|
"directory at template dst must not be recorded in manifest"
|
||||||
|
)
|
||||||
|
text = output.getvalue()
|
||||||
|
assert real_template in text, (
|
||||||
|
"directory-at-template-dst path must surface in the skipped warning"
|
||||||
|
)
|
||||||
|
|||||||
223
tests/integrations/test_integration_cline.py
Normal file
223
tests/integrations/test_integration_cline.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""Tests for ClineIntegration."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from specify_cli.integrations import get_integration
|
||||||
|
from specify_cli.integrations.cline import format_cline_command_name
|
||||||
|
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||||
|
|
||||||
|
|
||||||
|
class TestClineCommandNameFormatter:
|
||||||
|
"""Test the Cline command name formatter."""
|
||||||
|
|
||||||
|
def test_simple_name_without_prefix(self):
|
||||||
|
"""Test formatting a simple name without 'speckit.' prefix."""
|
||||||
|
assert format_cline_command_name("plan") == "speckit-plan"
|
||||||
|
assert format_cline_command_name("tasks") == "speckit-tasks"
|
||||||
|
assert format_cline_command_name("specify") == "speckit-specify"
|
||||||
|
|
||||||
|
def test_name_with_speckit_prefix(self):
|
||||||
|
"""Test formatting a name that already has 'speckit.' prefix."""
|
||||||
|
assert format_cline_command_name("speckit.plan") == "speckit-plan"
|
||||||
|
assert format_cline_command_name("speckit.tasks") == "speckit-tasks"
|
||||||
|
|
||||||
|
def test_extension_command_name(self):
|
||||||
|
"""Test formatting extension command names with dots."""
|
||||||
|
assert (
|
||||||
|
format_cline_command_name("speckit.my-extension.example")
|
||||||
|
== "speckit-my-extension-example"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
format_cline_command_name("my-extension.example")
|
||||||
|
== "speckit-my-extension-example"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_idempotent_already_hyphenated(self):
|
||||||
|
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
|
||||||
|
assert format_cline_command_name("speckit-plan") == "speckit-plan"
|
||||||
|
assert (
|
||||||
|
format_cline_command_name("speckit-my-extension-example")
|
||||||
|
== "speckit-my-extension-example"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClineIntegration(MarkdownIntegrationTests):
|
||||||
|
KEY = "cline"
|
||||||
|
FOLDER = ".clinerules/"
|
||||||
|
COMMANDS_SUBDIR = "workflows"
|
||||||
|
REGISTRAR_DIR = ".clinerules/workflows"
|
||||||
|
CONTEXT_FILE = ".clinerules/specify-rules.md"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd_name, expected_filename",
|
||||||
|
[
|
||||||
|
("plan", "speckit-plan.md"),
|
||||||
|
("speckit.plan", "speckit-plan.md"),
|
||||||
|
("speckit.git.commit", "speckit-git-commit.md"),
|
||||||
|
("speckit", "speckit-speckit.md"),
|
||||||
|
("speckitfoo", "speckit-speckitfoo.md"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_cline_command_filename(self, cmd_name, expected_filename):
|
||||||
|
"""Verify Cline uses hyphenated filenames."""
|
||||||
|
cline = get_integration("cline")
|
||||||
|
assert cline.command_filename(cmd_name) == expected_filename
|
||||||
|
|
||||||
|
def test_cline_invoke_separator(self):
|
||||||
|
"""Verify Cline uses hyphen as invoke separator."""
|
||||||
|
cline = get_integration("cline")
|
||||||
|
assert cline.invoke_separator == "-"
|
||||||
|
assert cline.registrar_config["invoke_separator"] == "-"
|
||||||
|
|
||||||
|
def test_cline_name_injection_and_formatting(self):
|
||||||
|
"""Verify Cline has inject_name and format_name configured."""
|
||||||
|
cline = get_integration("cline")
|
||||||
|
assert cline.registrar_config["inject_name"] is True
|
||||||
|
assert cline.registrar_config["format_name"] == format_cline_command_name
|
||||||
|
|
||||||
|
def test_cline_handoff_rewrite(self):
|
||||||
|
"""Verify Cline rewrites agent: speckit.foo to agent: speckit-foo."""
|
||||||
|
cline = get_integration("cline")
|
||||||
|
content = "---\nagent: speckit.plan\n---\n"
|
||||||
|
rewritten = cline._rewrite_handoff_references(content)
|
||||||
|
assert rewritten == "---\nagent: speckit-plan\n---\n"
|
||||||
|
|
||||||
|
def test_cline_hook_instruction_injection(self):
|
||||||
|
"""Verify Cline injects the dot-to-hyphen note for hooks."""
|
||||||
|
cline = get_integration("cline")
|
||||||
|
content = "- For each executable hook, output the following:\n"
|
||||||
|
injected = cline._inject_hook_command_note(content)
|
||||||
|
assert "replace dots (`.`) with hyphens (`-`)" in injected
|
||||||
|
assert "- For each executable hook, output the following:" in injected
|
||||||
|
|
||||||
|
# -- Overrides for MarkdownIntegrationTests ---------------------------
|
||||||
|
|
||||||
|
def test_setup_creates_files(self, tmp_path):
|
||||||
|
from specify_cli.integrations.manifest import IntegrationManifest
|
||||||
|
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
m = IntegrationManifest(self.KEY, tmp_path)
|
||||||
|
created = i.setup(tmp_path, m)
|
||||||
|
assert len(created) > 0
|
||||||
|
cmd_files = [
|
||||||
|
f
|
||||||
|
for f in created
|
||||||
|
if "scripts" not in f.parts
|
||||||
|
and f.suffix == ".md"
|
||||||
|
and f.name != i.context_file
|
||||||
|
]
|
||||||
|
for f in cmd_files:
|
||||||
|
assert f.exists()
|
||||||
|
assert f.name.startswith("speckit-")
|
||||||
|
assert f.name.endswith(".md")
|
||||||
|
|
||||||
|
specify_file = next(
|
||||||
|
(f for f in cmd_files if f.name == "speckit-specify.md"), None
|
||||||
|
)
|
||||||
|
assert specify_file is not None
|
||||||
|
specify_contents = specify_file.read_text(encoding="utf-8")
|
||||||
|
assert "/speckit-plan" in specify_contents
|
||||||
|
assert "/speckit.plan" not in specify_contents
|
||||||
|
|
||||||
|
def test_integration_flag_creates_files(self, tmp_path):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
project = tmp_path / f"int-{self.KEY}"
|
||||||
|
project.mkdir()
|
||||||
|
old_cwd = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(project)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"init",
|
||||||
|
"--here",
|
||||||
|
"--integration",
|
||||||
|
self.KEY,
|
||||||
|
"--script",
|
||||||
|
"sh",
|
||||||
|
"--no-git",
|
||||||
|
"--ignore-agent-tools",
|
||||||
|
],
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.chdir(old_cwd)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
cmd_dir = i.commands_dest(project)
|
||||||
|
assert cmd_dir.is_dir()
|
||||||
|
commands = sorted(cmd_dir.glob("speckit-*"))
|
||||||
|
assert len(commands) > 0
|
||||||
|
|
||||||
|
def _expected_files(self, script_variant: str) -> list[str]:
|
||||||
|
"""Override to expect hyphenated speckit- prefix."""
|
||||||
|
i = get_integration(self.KEY)
|
||||||
|
cmd_dir = i.registrar_config["dir"]
|
||||||
|
files = []
|
||||||
|
|
||||||
|
# Command files
|
||||||
|
for stem in (
|
||||||
|
self.COMMANDS_SUBDIR_STEMS
|
||||||
|
if hasattr(self, "COMMANDS_SUBDIR_STEMS")
|
||||||
|
else self.COMMAND_STEMS
|
||||||
|
):
|
||||||
|
files.append(f"{cmd_dir}/speckit-{stem.replace('.', '-')}.md")
|
||||||
|
|
||||||
|
# Framework files
|
||||||
|
files.append(".specify/integration.json")
|
||||||
|
files.append(".specify/init-options.json")
|
||||||
|
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||||
|
files.append(".specify/integrations/speckit.manifest.json")
|
||||||
|
|
||||||
|
if script_variant == "sh":
|
||||||
|
for name in [
|
||||||
|
"check-prerequisites.sh",
|
||||||
|
"common.sh",
|
||||||
|
"create-new-feature.sh",
|
||||||
|
"setup-plan.sh",
|
||||||
|
"setup-tasks.sh",
|
||||||
|
]:
|
||||||
|
files.append(f".specify/scripts/bash/{name}")
|
||||||
|
else:
|
||||||
|
for name in [
|
||||||
|
"check-prerequisites.ps1",
|
||||||
|
"common.ps1",
|
||||||
|
"create-new-feature.ps1",
|
||||||
|
"setup-plan.ps1",
|
||||||
|
"setup-tasks.ps1",
|
||||||
|
]:
|
||||||
|
files.append(f".specify/scripts/powershell/{name}")
|
||||||
|
|
||||||
|
for name in [
|
||||||
|
"checklist-template.md",
|
||||||
|
"constitution-template.md",
|
||||||
|
"plan-template.md",
|
||||||
|
"spec-template.md",
|
||||||
|
"tasks-template.md",
|
||||||
|
]:
|
||||||
|
files.append(f".specify/templates/{name}")
|
||||||
|
|
||||||
|
files.append(".specify/memory/constitution.md")
|
||||||
|
# Bundled workflow
|
||||||
|
files.append(".specify/workflows/speckit/workflow.yml")
|
||||||
|
files.append(".specify/workflows/workflow-registry.json")
|
||||||
|
|
||||||
|
# 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)
|
||||||
@@ -147,6 +147,21 @@ class TestCopilotIntegration:
|
|||||||
assert "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
|
assert "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||||
assert "\nscripts:\n" not in content
|
assert "\nscripts:\n" not in content
|
||||||
|
|
||||||
|
def test_specify_agent_resolves_active_spec_template(self, tmp_path):
|
||||||
|
"""Generated specify agent must not hardcode the core spec template."""
|
||||||
|
from specify_cli.integrations.copilot import CopilotIntegration
|
||||||
|
copilot = CopilotIntegration()
|
||||||
|
m = IntegrationManifest("copilot", tmp_path)
|
||||||
|
copilot.setup(tmp_path, m)
|
||||||
|
|
||||||
|
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
|
||||||
|
content = specify_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "specify preset resolve spec-template" in content
|
||||||
|
assert "resolved active `spec-template`" in content
|
||||||
|
assert "Copy `.specify/templates/spec-template.md`" not in content
|
||||||
|
assert "Load `.specify/templates/spec-template.md`" not in content
|
||||||
|
|
||||||
def test_plan_references_correct_context_file(self, tmp_path):
|
def test_plan_references_correct_context_file(self, tmp_path):
|
||||||
"""The generated plan command must reference copilot's context file."""
|
"""The generated plan command must reference copilot's context file."""
|
||||||
from specify_cli.integrations.copilot import CopilotIntegration
|
from specify_cli.integrations.copilot import CopilotIntegration
|
||||||
@@ -411,8 +426,8 @@ class TestCopilotSkillsMode:
|
|||||||
|
|
||||||
# -- Copilot-specific post-processing ---------------------------------
|
# -- Copilot-specific post-processing ---------------------------------
|
||||||
|
|
||||||
def test_post_process_skill_content_injects_mode(self):
|
def test_post_process_skill_content_does_not_inject_mode(self):
|
||||||
"""post_process_skill_content() should inject mode: field."""
|
"""post_process_skill_content() must NOT inject mode: — VS Code Copilot does not support it."""
|
||||||
copilot = self._make_copilot()
|
copilot = self._make_copilot()
|
||||||
content = (
|
content = (
|
||||||
"---\n"
|
"---\n"
|
||||||
@@ -422,10 +437,10 @@ class TestCopilotSkillsMode:
|
|||||||
"\nBody content\n"
|
"\nBody content\n"
|
||||||
)
|
)
|
||||||
updated = copilot.post_process_skill_content(content)
|
updated = copilot.post_process_skill_content(content)
|
||||||
assert "mode: speckit.plan" in updated
|
assert "mode:" not in updated
|
||||||
|
|
||||||
def test_post_process_skill_content_injects_hook_note(self):
|
def test_post_process_skill_content_injects_hook_note(self):
|
||||||
"""post_process_skill_content() should inject shared hook guidance."""
|
"""post_process_skill_content() should inject shared hook guidance but not mode:."""
|
||||||
copilot = self._make_copilot()
|
copilot = self._make_copilot()
|
||||||
content = (
|
content = (
|
||||||
"---\n"
|
"---\n"
|
||||||
@@ -436,7 +451,7 @@ class TestCopilotSkillsMode:
|
|||||||
)
|
)
|
||||||
updated = copilot.post_process_skill_content(content)
|
updated = copilot.post_process_skill_content(content)
|
||||||
assert "replace dots" in updated
|
assert "replace dots" in updated
|
||||||
assert "mode: speckit.specify" in updated
|
assert "mode:" not in updated
|
||||||
|
|
||||||
def test_post_process_idempotent(self):
|
def test_post_process_idempotent(self):
|
||||||
"""post_process_skill_content() must be idempotent."""
|
"""post_process_skill_content() must be idempotent."""
|
||||||
@@ -452,8 +467,8 @@ class TestCopilotSkillsMode:
|
|||||||
second = copilot.post_process_skill_content(first)
|
second = copilot.post_process_skill_content(first)
|
||||||
assert first == second
|
assert first == second
|
||||||
|
|
||||||
def test_skills_have_mode_in_frontmatter(self, tmp_path):
|
def test_skills_do_not_have_mode_in_frontmatter(self, tmp_path):
|
||||||
"""Generated SKILL.md files should have mode: field from post-processing."""
|
"""Generated SKILL.md files must NOT contain mode: — VS Code Copilot does not support it."""
|
||||||
copilot = self._make_copilot()
|
copilot = self._make_copilot()
|
||||||
created, _ = self._setup_skills(copilot, tmp_path)
|
created, _ = self._setup_skills(copilot, tmp_path)
|
||||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||||
@@ -462,11 +477,7 @@ class TestCopilotSkillsMode:
|
|||||||
content = f.read_text(encoding="utf-8")
|
content = f.read_text(encoding="utf-8")
|
||||||
parts = content.split("---", 2)
|
parts = content.split("---", 2)
|
||||||
fm = yaml.safe_load(parts[1])
|
fm = yaml.safe_load(parts[1])
|
||||||
assert "mode" in fm, f"{f} frontmatter missing 'mode'"
|
assert "mode" not in fm, f"{f} frontmatter must not contain unsupported 'mode' field"
|
||||||
# mode should be speckit.<stem>
|
|
||||||
skill_dir_name = f.parent.name
|
|
||||||
stem = skill_dir_name.removeprefix("speckit-")
|
|
||||||
assert fm["mode"] == f"speckit.{stem}"
|
|
||||||
|
|
||||||
def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
||||||
"""Generated skills with hook sections should include shared hook guidance."""
|
"""Generated skills with hook sections should include shared hook guidance."""
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ class TestForgeCommandRegistrar:
|
|||||||
assert "speckit.my-extension.example" in registered
|
assert "speckit.my-extension.example" in registered
|
||||||
|
|
||||||
# Check the generated file has hyphenated name in frontmatter
|
# Check the generated file has hyphenated name in frontmatter
|
||||||
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.my-extension.example.md"
|
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-my-extension-example.md"
|
||||||
assert forge_cmd.exists()
|
assert forge_cmd.exists()
|
||||||
|
|
||||||
content = forge_cmd.read_text(encoding="utf-8")
|
content = forge_cmd.read_text(encoding="utf-8")
|
||||||
@@ -378,7 +378,7 @@ class TestForgeCommandRegistrar:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check the alias file has hyphenated name in frontmatter
|
# Check the alias file has hyphenated name in frontmatter
|
||||||
alias_file = tmp_path / ".forge" / "commands" / "speckit.my-extension.ex.md"
|
alias_file = tmp_path / ".forge" / "commands" / "speckit-my-extension-ex.md"
|
||||||
assert alias_file.exists()
|
assert alias_file.exists()
|
||||||
|
|
||||||
content = alias_file.read_text(encoding="utf-8")
|
content = alias_file.read_text(encoding="utf-8")
|
||||||
@@ -467,7 +467,7 @@ class TestForgeCommandRegistrar:
|
|||||||
|
|
||||||
assert "speckit.git.feature" in registered
|
assert "speckit.git.feature" in registered
|
||||||
|
|
||||||
forge_cmd = tmp_path / ".forge" / "commands" / "speckit.git.feature.md"
|
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-git-feature.md"
|
||||||
assert forge_cmd.exists(), "Expected Forge command file was not created"
|
assert forge_cmd.exists(), "Expected Forge command file was not created"
|
||||||
|
|
||||||
content = forge_cmd.read_text(encoding="utf-8")
|
content = forge_cmd.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -185,7 +185,8 @@ class TestIntegrationInstall:
|
|||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
normalized = " ".join(result.output.split())
|
output = strip_ansi(result.output)
|
||||||
|
normalized = " ".join(output.split())
|
||||||
assert "already installed" in normalized
|
assert "already installed" in normalized
|
||||||
assert "specify integration use codex" in normalized
|
assert "specify integration use codex" in normalized
|
||||||
assert "specify integration upgrade codex" in normalized
|
assert "specify integration upgrade codex" in normalized
|
||||||
@@ -241,9 +242,9 @@ class TestIntegrationInstall:
|
|||||||
opts["speckit_version"] = "0.6.1"
|
opts["speckit_version"] = "0.6.1"
|
||||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||||
|
|
||||||
import specify_cli
|
import specify_cli.integrations._commands as _int_cmds
|
||||||
|
|
||||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
|
||||||
|
|
||||||
result = _run_in_project(project, [
|
result = _run_in_project(project, [
|
||||||
"integration", "install", "codex",
|
"integration", "install", "codex",
|
||||||
@@ -897,11 +898,10 @@ class TestIntegrationSwitch:
|
|||||||
assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
|
assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
|
||||||
assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"
|
assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"
|
||||||
|
|
||||||
# Verify Copilot-specific frontmatter: mode field should map from
|
# Verify Copilot skill frontmatter does NOT contain mode: — VS Code Copilot does not support it
|
||||||
# skill name (speckit-git-feature) back to dot notation (speckit.git-feature)
|
|
||||||
skill_content = copilot_git_feature.read_text(encoding="utf-8")
|
skill_content = copilot_git_feature.read_text(encoding="utf-8")
|
||||||
assert "mode: speckit.git-feature" in skill_content, (
|
assert "mode:" not in skill_content, (
|
||||||
"Copilot skill frontmatter should contain mode mapped from skill name"
|
"Copilot skill frontmatter must not contain unsupported 'mode' field"
|
||||||
)
|
)
|
||||||
|
|
||||||
registry = json.loads(
|
registry = json.loads(
|
||||||
@@ -1209,9 +1209,9 @@ class TestIntegrationUpgrade:
|
|||||||
opts["speckit_version"] = "0.6.1"
|
opts["speckit_version"] = "0.6.1"
|
||||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||||
|
|
||||||
import specify_cli
|
import specify_cli.integrations._commands as _int_cmds
|
||||||
|
|
||||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
|
||||||
|
|
||||||
result = _run_in_project(project, [
|
result = _run_in_project(project, [
|
||||||
"integration", "upgrade", "claude",
|
"integration", "upgrade", "claude",
|
||||||
@@ -1235,9 +1235,9 @@ class TestIntegrationUpgrade:
|
|||||||
opts["speckit_version"] = "0.6.1"
|
opts["speckit_version"] = "0.6.1"
|
||||||
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
init_options.write_text(json.dumps(opts), encoding="utf-8")
|
||||||
|
|
||||||
import specify_cli
|
import specify_cli.integrations._commands as _int_cmds
|
||||||
|
|
||||||
monkeypatch.setattr(specify_cli, "get_speckit_version", lambda: "0.8.11")
|
monkeypatch.setattr(_int_cmds, "get_speckit_version", lambda: "0.8.11")
|
||||||
|
|
||||||
result = _run_in_project(project, [
|
result = _run_in_project(project, [
|
||||||
"integration", "upgrade", "claude",
|
"integration", "upgrade", "claude",
|
||||||
@@ -1471,7 +1471,7 @@ class TestScriptTypeValidation:
|
|||||||
class TestParseIntegrationOptionsEqualsForm:
|
class TestParseIntegrationOptionsEqualsForm:
|
||||||
def test_equals_form_parsed(self):
|
def test_equals_form_parsed(self):
|
||||||
"""--commands-dir=./x should be parsed the same as --commands-dir ./x."""
|
"""--commands-dir=./x should be parsed the same as --commands-dir ./x."""
|
||||||
from specify_cli import _parse_integration_options
|
from specify_cli.integrations._commands import _parse_integration_options
|
||||||
from specify_cli.integrations import get_integration
|
from specify_cli.integrations import get_integration
|
||||||
|
|
||||||
integration = get_integration("generic")
|
integration = get_integration("generic")
|
||||||
|
|||||||
@@ -34,6 +34,57 @@ class TestManifestRecordFile:
|
|||||||
assert m.files["existing.txt"] == _sha256(f)
|
assert m.files["existing.txt"] == _sha256(f)
|
||||||
|
|
||||||
|
|
||||||
|
class TestManifestRecordExistingErrors:
|
||||||
|
"""Error-case coverage for ``record_existing`` symlink + non-file guards.
|
||||||
|
|
||||||
|
Added in #2483 — Copilot review flagged these as un-tested regressions
|
||||||
|
after the ``is_symlink``/``is_file`` guards were introduced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_rejects_symlink_target(self, tmp_path):
|
||||||
|
target = tmp_path / "target.txt"
|
||||||
|
target.write_text("target content", encoding="utf-8")
|
||||||
|
link = tmp_path / "link.txt"
|
||||||
|
link.symlink_to(target)
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
with pytest.raises(ValueError, match="symlinked"):
|
||||||
|
m.record_existing("link.txt")
|
||||||
|
|
||||||
|
def test_rejects_dangling_symlink(self, tmp_path):
|
||||||
|
# A symlink pointing nowhere should still be rejected before the
|
||||||
|
# ``is_file()`` check (which would itself be False on a dangler).
|
||||||
|
link = tmp_path / "dangler.txt"
|
||||||
|
link.symlink_to(tmp_path / "no-such-target.txt")
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
with pytest.raises(ValueError, match="symlinked"):
|
||||||
|
m.record_existing("dangler.txt")
|
||||||
|
|
||||||
|
def test_rejects_directory_path(self, tmp_path):
|
||||||
|
(tmp_path / "a_dir").mkdir()
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
with pytest.raises(ValueError, match="not a regular file"):
|
||||||
|
m.record_existing("a_dir")
|
||||||
|
|
||||||
|
def test_rejects_missing_path(self, tmp_path):
|
||||||
|
# ``is_file()`` is False for non-existent paths too; the same error
|
||||||
|
# surface keeps callers from having to distinguish "missing" from
|
||||||
|
# "wrong kind" — both mean "cannot hash this".
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
with pytest.raises(ValueError, match="not a regular file"):
|
||||||
|
m.record_existing("never-existed.txt")
|
||||||
|
|
||||||
|
def test_lexical_prevalidation_for_absolute_path(self, tmp_path):
|
||||||
|
# ``record_existing`` must reject absolute paths via the lexical
|
||||||
|
# pre-check, NOT via the filesystem-touching ``is_symlink()`` call.
|
||||||
|
# Verified by passing an absolute path that points to a directory
|
||||||
|
# outside the project root — the canonical "Absolute paths" error
|
||||||
|
# must surface before any stat on the absolute path.
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt"
|
||||||
|
with pytest.raises(ValueError, match="Absolute paths"):
|
||||||
|
m.record_existing(abs_path)
|
||||||
|
|
||||||
|
|
||||||
class TestManifestPathTraversal:
|
class TestManifestPathTraversal:
|
||||||
def test_record_file_rejects_parent_traversal(self, tmp_path):
|
def test_record_file_rejects_parent_traversal(self, tmp_path):
|
||||||
m = IntegrationManifest("test", tmp_path)
|
m = IntegrationManifest("test", tmp_path)
|
||||||
@@ -245,3 +296,160 @@ class TestManifestLoadValidation:
|
|||||||
path.write_text("{not valid json", encoding="utf-8")
|
path.write_text("{not valid json", encoding="utf-8")
|
||||||
with pytest.raises(ValueError, match="invalid JSON"):
|
with pytest.raises(ValueError, match="invalid JSON"):
|
||||||
IntegrationManifest.load("bad", tmp_path)
|
IntegrationManifest.load("bad", tmp_path)
|
||||||
|
|
||||||
|
def test_load_filters_recovered_files_not_in_files(self, tmp_path):
|
||||||
|
# Finding B (Round-9): a recovered_files entry referencing a path
|
||||||
|
# not present in files indicates an internally-inconsistent manifest
|
||||||
|
# (e.g. external edit). load() filters those entries silently so the
|
||||||
|
# manifest self-heals on next save(); is_recovered then returns the
|
||||||
|
# truthful False for the orphan.
|
||||||
|
path = tmp_path / ".specify" / "integrations" / "test.manifest.json"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text(json.dumps({
|
||||||
|
"integration": "test",
|
||||||
|
"files": {"kept.txt": "abc123"},
|
||||||
|
"recovered_files": ["kept.txt", "orphan.txt"],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
m = IntegrationManifest.load("test", tmp_path)
|
||||||
|
assert m.recovered_files == {"kept.txt"}
|
||||||
|
assert m.is_recovered("kept.txt") is True
|
||||||
|
assert m.is_recovered("orphan.txt") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestManifestRecoveredFiles:
|
||||||
|
"""Coverage for the ``recovered_files`` channel added in #2483.
|
||||||
|
|
||||||
|
When ``shared_infra`` skips an existing file (because the user already has
|
||||||
|
it on disk) it now records the file with ``recovered=True``. The path
|
||||||
|
appears in ``manifest.recovered_files`` and ``is_recovered(path)`` returns
|
||||||
|
True. ``refresh_managed`` (out of scope for this PR) consults this list
|
||||||
|
before treating the recorded hash as a managed baseline, defending against
|
||||||
|
silent overwrite of user customizations after manifest loss.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_record_existing_default_is_not_recovered(self, tmp_path):
|
||||||
|
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
m.record_existing("f.txt")
|
||||||
|
assert m.is_recovered("f.txt") is False
|
||||||
|
assert m.recovered_files == set()
|
||||||
|
|
||||||
|
def test_record_existing_with_recovered_flag(self, tmp_path):
|
||||||
|
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
m.record_existing("f.txt", recovered=True)
|
||||||
|
assert m.is_recovered("f.txt") is True
|
||||||
|
assert m.recovered_files == {"f.txt"}
|
||||||
|
# File still hashed normally so check_modified/uninstall keep working
|
||||||
|
assert m.files["f.txt"] == _sha256(tmp_path / "f.txt")
|
||||||
|
|
||||||
|
def test_recovered_files_round_trips_through_save_load(self, tmp_path):
|
||||||
|
(tmp_path / "a.txt").write_text("aaa", encoding="utf-8")
|
||||||
|
(tmp_path / "b.txt").write_text("bbb", encoding="utf-8")
|
||||||
|
m = IntegrationManifest("test", tmp_path, version="9.9")
|
||||||
|
m.record_existing("a.txt", recovered=True)
|
||||||
|
m.record_existing("b.txt") # not recovered
|
||||||
|
m.save()
|
||||||
|
loaded = IntegrationManifest.load("test", tmp_path)
|
||||||
|
assert loaded.is_recovered("a.txt") is True
|
||||||
|
assert loaded.is_recovered("b.txt") is False
|
||||||
|
assert loaded.recovered_files == {"a.txt"}
|
||||||
|
|
||||||
|
def test_save_omits_empty_recovered_files(self, tmp_path):
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
m.record_file("f.txt", "x")
|
||||||
|
path = m.save()
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
assert "recovered_files" not in data
|
||||||
|
|
||||||
|
def test_load_rejects_non_list_recovered_files(self, tmp_path):
|
||||||
|
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"files": {}, "recovered_files": "not-a-list"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="recovered_files"):
|
||||||
|
IntegrationManifest.load("bad", tmp_path)
|
||||||
|
|
||||||
|
def test_is_recovered_absolute_path_returns_false(self, tmp_path):
|
||||||
|
# Copilot round-5 finding: passing an absolute path silently returned
|
||||||
|
# False because the stored keys are relative POSIX strings. Round-7
|
||||||
|
# made this explicit: ``is_recovered`` now rejects absolute paths
|
||||||
|
# up front via a lexical ``rel.is_absolute()`` guard and returns
|
||||||
|
# False without calling ``_validate_rel_path`` at all — matching
|
||||||
|
# ``record_existing``'s canonical-key guard so the two methods
|
||||||
|
# agree on which inputs can ever be stored keys.
|
||||||
|
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
m.record_existing("f.txt", recovered=True)
|
||||||
|
import sys
|
||||||
|
abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt"
|
||||||
|
assert m.is_recovered(abs_input) is False
|
||||||
|
|
||||||
|
def test_is_recovered_escaping_path_returns_false(self, tmp_path):
|
||||||
|
# A relative path containing ``..`` segments cannot be a stored key:
|
||||||
|
# Round-7 added the same lexical ``".." in rel.parts`` guard to
|
||||||
|
# ``is_recovered`` that ``record_existing`` already enforces, so the
|
||||||
|
# method returns False immediately without reaching
|
||||||
|
# ``_validate_rel_path``. The try/except around ``_validate_rel_path``
|
||||||
|
# remains as defense-in-depth for paths that pass the lexical guard
|
||||||
|
# but still resolve outside the project root via a symlinked
|
||||||
|
# ancestor.
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
# Don't record anything — the path is impossible to record anyway.
|
||||||
|
assert m.is_recovered("../escape.txt") is False
|
||||||
|
|
||||||
|
def test_record_existing_clears_recovered_when_false(self, tmp_path):
|
||||||
|
# Finding A: re-recording the same path with recovered=False must
|
||||||
|
# drop the prior recovered marker (transition to managed baseline).
|
||||||
|
f = tmp_path / "x.txt"
|
||||||
|
f.write_text("v1", encoding="utf-8")
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
m.record_existing("x.txt", recovered=True)
|
||||||
|
assert m.is_recovered("x.txt") is True
|
||||||
|
m.record_existing("x.txt", recovered=False)
|
||||||
|
assert m.is_recovered("x.txt") is False
|
||||||
|
|
||||||
|
def test_record_file_clears_recovered(self, tmp_path):
|
||||||
|
# Finding A: record_file writes produced content; the path can no
|
||||||
|
# longer be considered "merely observed" once we wrote bytes.
|
||||||
|
(tmp_path / "y.txt").write_text("observed", encoding="utf-8")
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
m.record_existing("y.txt", recovered=True)
|
||||||
|
assert m.is_recovered("y.txt") is True
|
||||||
|
m.record_file("y.txt", "produced")
|
||||||
|
assert m.is_recovered("y.txt") is False
|
||||||
|
|
||||||
|
def test_is_recovered_rejects_dotdot_segment(self, tmp_path):
|
||||||
|
# Finding B: record_existing rejects ``..`` segments via the lexical
|
||||||
|
# pre-check; is_recovered must match that behavior and return False
|
||||||
|
# without raising, mirroring the canonicalization guard.
|
||||||
|
(tmp_path / "z.txt").write_text("v1", encoding="utf-8")
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
m.record_existing("z.txt", recovered=True)
|
||||||
|
# Same file via dotdot-normalizing path — must be False, not raise.
|
||||||
|
assert m.is_recovered("subdir/../z.txt") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordExistingNewGuards:
|
||||||
|
"""Coverage for the two new guards added by Copilot's 2026-05-18 review."""
|
||||||
|
|
||||||
|
def test_rejects_symlinked_ancestor(self, tmp_path):
|
||||||
|
real_dir = tmp_path / "real_dir"
|
||||||
|
real_dir.mkdir()
|
||||||
|
(real_dir / "file.txt").write_text("payload", encoding="utf-8")
|
||||||
|
(tmp_path / "linked_dir").symlink_to(real_dir, target_is_directory=True)
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
with pytest.raises(ValueError, match="symlinked"):
|
||||||
|
m.record_existing("linked_dir/file.txt")
|
||||||
|
|
||||||
|
def test_rejects_inside_root_dotdot_with_explicit_message(self, tmp_path):
|
||||||
|
# ``dir/../file.txt`` normalizes inside root, so the old "escapes
|
||||||
|
# project root" message was misleading. The new message names the
|
||||||
|
# actual reason: canonicalization.
|
||||||
|
(tmp_path / "dir").mkdir()
|
||||||
|
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
|
||||||
|
m = IntegrationManifest("test", tmp_path)
|
||||||
|
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
|
||||||
|
m.record_existing("dir/../file.txt")
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ def test_commands_init_importable():
|
|||||||
assert callable(mod.register)
|
assert callable(mod.register)
|
||||||
|
|
||||||
|
|
||||||
def test_commands_stubs_importable():
|
|
||||||
for name in ("integration", "preset", "extension", "workflow"):
|
|
||||||
mod = importlib.import_module(f"specify_cli.commands.{name}")
|
|
||||||
assert mod is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_config_importable():
|
def test_agent_config_importable():
|
||||||
from specify_cli._agent_config import (
|
from specify_cli._agent_config import (
|
||||||
AGENT_CONFIG,
|
AGENT_CONFIG,
|
||||||
|
|||||||
@@ -1315,6 +1315,42 @@ $ARGUMENTS
|
|||||||
assert not (skills_dir / "speckit-specify" / "SKILL.md").exists()
|
assert not (skills_dir / "speckit-specify" / "SKILL.md").exists()
|
||||||
assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists()
|
assert not (skills_dir / "speckit-shortcut" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
def test_unregister_commands_handles_legacy_dot_notated_files(self, project_dir):
|
||||||
|
"""Unregister should clean up both legacy dot-notated and new hyphenated files."""
|
||||||
|
# 1. Mock an agent that uses hyphenated/formatted names (e.g. Cline)
|
||||||
|
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||||
|
registrar = AgentCommandRegistrar()
|
||||||
|
|
||||||
|
# We'll use "cline" since it has format_name
|
||||||
|
assert "cline" in registrar.AGENT_CONFIGS
|
||||||
|
cline_config = registrar.AGENT_CONFIGS["cline"]
|
||||||
|
cline_dir = project_dir / cline_config["dir"]
|
||||||
|
cline_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 2. Create both legacy and new files
|
||||||
|
# Command name: speckit.git.commit
|
||||||
|
# Formatted name: speckit-git-commit
|
||||||
|
cmd_name = "speckit.git.commit"
|
||||||
|
formatted_name = "speckit-git-commit"
|
||||||
|
|
||||||
|
legacy_file = cline_dir / f"{cmd_name}.md"
|
||||||
|
formatted_file = cline_dir / f"{formatted_name}.md"
|
||||||
|
|
||||||
|
legacy_file.write_text("legacy body")
|
||||||
|
formatted_file.write_text("formatted body")
|
||||||
|
|
||||||
|
assert legacy_file.exists()
|
||||||
|
assert formatted_file.exists()
|
||||||
|
|
||||||
|
# 3. Call unregister
|
||||||
|
registrar.unregister_commands({"cline": [cmd_name]}, project_dir)
|
||||||
|
|
||||||
|
# 4. Verify both are gone
|
||||||
|
assert not legacy_file.exists(), "Legacy dot-notated file should be removed"
|
||||||
|
assert (
|
||||||
|
not formatted_file.exists()
|
||||||
|
), "Formatted hyphenated file should be removed"
|
||||||
|
|
||||||
def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir):
|
def test_register_commands_for_all_agents_distinguishes_codex_from_amp(self, extension_dir, project_dir):
|
||||||
"""A Codex project under .agents/skills should not implicitly activate Amp."""
|
"""A Codex project under .agents/skills should not implicitly activate Amp."""
|
||||||
skills_dir = project_dir / ".agents" / "skills"
|
skills_dir = project_dir / ".agents" / "skills"
|
||||||
@@ -2768,17 +2804,33 @@ class TestExtensionCatalog:
|
|||||||
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
|
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
|
||||||
zip_bytes = zip_buf.getvalue()
|
zip_bytes = zip_buf.getvalue()
|
||||||
|
|
||||||
mock_response = MagicMock()
|
release_response = MagicMock()
|
||||||
mock_response.read.return_value = zip_bytes
|
release_response.read.return_value = json.dumps(
|
||||||
mock_response.__enter__ = lambda s: s
|
{
|
||||||
mock_response.__exit__ = MagicMock(return_value=False)
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "test-ext.zip",
|
||||||
|
"url": "https://api.github.com/repos/org/repo/releases/assets/1",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
release_response.__enter__ = lambda s: s
|
||||||
|
release_response.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
captured = {}
|
asset_response = MagicMock()
|
||||||
|
asset_response.read.return_value = zip_bytes
|
||||||
|
asset_response.__enter__ = lambda s: s
|
||||||
|
asset_response.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
captured = []
|
||||||
mock_opener = MagicMock()
|
mock_opener = MagicMock()
|
||||||
|
|
||||||
def fake_open(req, timeout=None):
|
def fake_open(req, timeout=None):
|
||||||
captured["req"] = req
|
captured.append(req)
|
||||||
return mock_response
|
if req.full_url.endswith("/releases/tags/v1"):
|
||||||
|
return release_response
|
||||||
|
return asset_response
|
||||||
|
|
||||||
mock_opener.open.side_effect = fake_open
|
mock_opener.open.side_effect = fake_open
|
||||||
|
|
||||||
@@ -2793,7 +2845,56 @@ class TestExtensionCatalog:
|
|||||||
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||||
catalog.download_extension("test-ext", target_dir=temp_dir)
|
catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||||
|
|
||||||
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
|
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/tags/v1"
|
||||||
|
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
|
assert captured[1].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
|
||||||
|
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
|
assert captured[1].get_header("Accept") == "application/octet-stream"
|
||||||
|
|
||||||
|
def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch):
|
||||||
|
"""download_extension can use a GitHub REST release asset URL directly."""
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
import zipfile
|
||||||
|
import io
|
||||||
|
|
||||||
|
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
|
||||||
|
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||||
|
catalog = self._make_catalog(temp_dir)
|
||||||
|
|
||||||
|
zip_buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buf, "w") as zf:
|
||||||
|
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
|
||||||
|
zip_bytes = zip_buf.getvalue()
|
||||||
|
|
||||||
|
asset_response = MagicMock()
|
||||||
|
asset_response.read.return_value = zip_bytes
|
||||||
|
asset_response.__enter__ = lambda s: s
|
||||||
|
asset_response.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
captured = []
|
||||||
|
mock_opener = MagicMock()
|
||||||
|
|
||||||
|
def fake_open(req, timeout=None):
|
||||||
|
captured.append(req)
|
||||||
|
return asset_response
|
||||||
|
|
||||||
|
mock_opener.open.side_effect = fake_open
|
||||||
|
|
||||||
|
ext_info = {
|
||||||
|
"id": "test-ext",
|
||||||
|
"name": "Test Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"download_url": "https://api.github.com/repos/org/repo/releases/assets/1",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||||
|
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||||
|
catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||||
|
|
||||||
|
assert len(captured) == 1
|
||||||
|
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
|
||||||
|
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||||
|
assert captured[0].get_header("Accept") == "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -3322,9 +3423,13 @@ class TestExtensionIgnore:
|
|||||||
else:
|
else:
|
||||||
p.write_text(content)
|
p.write_text(content)
|
||||||
|
|
||||||
# Write .extensionignore
|
# Write .extensionignore. Pinned to UTF-8 so non-ASCII patterns
|
||||||
|
# in tests (see ``test_extensionignore_utf8_patterns``) survive
|
||||||
|
# the round-trip on Windows runners with non-UTF-8 default locales.
|
||||||
if ignore_content is not None:
|
if ignore_content is not None:
|
||||||
(ext_dir / ".extensionignore").write_text(ignore_content)
|
(ext_dir / ".extensionignore").write_text(
|
||||||
|
ignore_content, encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
return ext_dir
|
return ext_dir
|
||||||
|
|
||||||
@@ -3554,6 +3659,73 @@ class TestExtensionIgnore:
|
|||||||
assert (dest / "docs" / "guide.md").exists()
|
assert (dest / "docs" / "guide.md").exists()
|
||||||
assert not (dest / "docs" / "internal" / "draft.md").exists()
|
assert not (dest / "docs" / "internal" / "draft.md").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_utf8_patterns(self, temp_dir, valid_manifest_data):
|
||||||
|
"""Non-ASCII patterns in .extensionignore work on every locale.
|
||||||
|
|
||||||
|
``Path.read_text`` defaults to the system locale codec on Windows
|
||||||
|
(cp1252 / gb2312 / cp932). Without an explicit ``encoding="utf-8"``,
|
||||||
|
a pattern like ``ドキュメント/`` written by a UTF-8 host becomes
|
||||||
|
mojibake on a cp1252 host and silently fails to match — leaking
|
||||||
|
files the author intended to exclude. The existing
|
||||||
|
``test_extensionignore_windows_backslash_patterns`` already shows
|
||||||
|
the codebase treats this as a Windows-author-friendly file; UTF-8
|
||||||
|
is part of that same contract.
|
||||||
|
"""
|
||||||
|
ext_dir = self._make_extension(
|
||||||
|
temp_dir,
|
||||||
|
valid_manifest_data,
|
||||||
|
extra_files={
|
||||||
|
"ドキュメント/private.md": "secret",
|
||||||
|
"ドキュメント/public.md": "public",
|
||||||
|
"docs/guide.md": "# Guide",
|
||||||
|
"café/résumé.txt": "draft",
|
||||||
|
},
|
||||||
|
ignore_content="ドキュメント/\ncafé/\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
dest = proj_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
# Multibyte patterns excluded.
|
||||||
|
assert not (dest / "ドキュメント").exists()
|
||||||
|
assert not (dest / "café").exists()
|
||||||
|
# ASCII path with no matching pattern is unaffected.
|
||||||
|
assert (dest / "docs" / "guide.md").exists()
|
||||||
|
|
||||||
|
def test_extensionignore_invalid_utf8_raises_validation_error(
|
||||||
|
self, temp_dir, valid_manifest_data
|
||||||
|
):
|
||||||
|
"""A non-UTF-8 ``.extensionignore`` surfaces as ``ValidationError``.
|
||||||
|
|
||||||
|
Pinning ``encoding="utf-8"`` on the reader means an
|
||||||
|
``.extensionignore`` written in some other codec (cp1252, etc.)
|
||||||
|
now triggers ``UnicodeDecodeError`` instead of silently
|
||||||
|
mojibake-ing patterns. Wrap that exception as ``ValidationError``
|
||||||
|
with a pointer to the offending byte — the same pattern
|
||||||
|
``ExtensionManifest._load_yaml`` uses for ``extension.yml`` —
|
||||||
|
so installation aborts with a user-friendly message instead of a
|
||||||
|
raw Python traceback.
|
||||||
|
"""
|
||||||
|
ext_dir = self._make_extension(temp_dir, valid_manifest_data)
|
||||||
|
# Write an .extensionignore whose bytes are not valid UTF-8.
|
||||||
|
# 0xE9 is 'é' in cp1252 but an invalid lead byte in UTF-8.
|
||||||
|
(ext_dir / ".extensionignore").write_bytes(b"caf\xe9/\n")
|
||||||
|
|
||||||
|
proj_dir = temp_dir / "project"
|
||||||
|
proj_dir.mkdir()
|
||||||
|
(proj_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
manager = ExtensionManager(proj_dir)
|
||||||
|
with pytest.raises(
|
||||||
|
ValidationError, match=r"\.extensionignore is not valid UTF-8"
|
||||||
|
):
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data):
|
def test_extensionignore_star_does_not_cross_directories(self, temp_dir, valid_manifest_data):
|
||||||
"""'*' should NOT match across directory boundaries (gitignore semantics)."""
|
"""'*' should NOT match across directory boundaries (gitignore semantics)."""
|
||||||
ext_dir = self._make_extension(
|
ext_dir = self._make_extension(
|
||||||
@@ -4616,6 +4788,43 @@ class TestHookInvocationRendering:
|
|||||||
assert execution["command"] == "speckit.tasks"
|
assert execution["command"] == "speckit.tasks"
|
||||||
assert execution["invocation"] == "$speckit-tasks"
|
assert execution["invocation"] == "$speckit-tasks"
|
||||||
|
|
||||||
|
def test_cline_hooks_render_hyphenated_invocation(self, project_dir):
|
||||||
|
"""Cline projects should render /speckit-* invocations."""
|
||||||
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
init_options.write_text(json.dumps({"ai": "cline"}))
|
||||||
|
|
||||||
|
hook_executor = HookExecutor(project_dir)
|
||||||
|
execution = hook_executor.execute_hook(
|
||||||
|
{
|
||||||
|
"extension": "test-ext",
|
||||||
|
"command": "speckit.tasks",
|
||||||
|
"optional": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert execution["command"] == "speckit.tasks"
|
||||||
|
assert execution["invocation"] == "/speckit-tasks"
|
||||||
|
|
||||||
|
def test_cline_hooks_render_extension_command(self, project_dir):
|
||||||
|
"""Cline projects should render /speckit-my-ext-cmd for extension hooks."""
|
||||||
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
init_options.write_text(json.dumps({"ai": "cline"}))
|
||||||
|
|
||||||
|
hook_executor = HookExecutor(project_dir)
|
||||||
|
# Test with a non-speckit. command
|
||||||
|
execution = hook_executor.execute_hook(
|
||||||
|
{
|
||||||
|
"extension": "test-ext",
|
||||||
|
"command": "my-extension.do-something",
|
||||||
|
"optional": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert execution["command"] == "my-extension.do-something"
|
||||||
|
assert execution["invocation"] == "/speckit-my-extension-do-something"
|
||||||
|
|
||||||
def test_non_skill_command_keeps_slash_invocation(self, project_dir):
|
def test_non_skill_command_keeps_slash_invocation(self, project_dir):
|
||||||
"""Custom hook commands should keep slash invocation style."""
|
"""Custom hook commands should keep slash invocation style."""
|
||||||
init_options = project_dir / ".specify" / "init-options.json"
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
@@ -4751,3 +4960,157 @@ class TestExtensionRemoveCLI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "2 commands" in result.output
|
assert "2 commands" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestClineExtensionHyphenation:
|
||||||
|
"""Test that Cline integration uses hyphenated commands and frontmatter references."""
|
||||||
|
|
||||||
|
def _setup_mock_extension(self, tmp_path, ai_name):
|
||||||
|
import yaml
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 1. Setup mock project
|
||||||
|
project_dir = tmp_path / "project"
|
||||||
|
project_dir.mkdir()
|
||||||
|
(project_dir / ".specify").mkdir()
|
||||||
|
|
||||||
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
|
init_options.write_text(json.dumps({"ai": ai_name}), encoding="utf-8")
|
||||||
|
|
||||||
|
if ai_name == "cline":
|
||||||
|
commands_dest_dir = project_dir / ".clinerules" / "workflows"
|
||||||
|
else:
|
||||||
|
commands_dest_dir = project_dir / ".agents" / "commands"
|
||||||
|
commands_dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 2. Setup mock extension directory
|
||||||
|
ext_dir = tmp_path / "mock-ext"
|
||||||
|
ext_dir.mkdir()
|
||||||
|
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": "mock-ext",
|
||||||
|
"name": "Mock Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": f"Mock extension for {ai_name} tests",
|
||||||
|
"author": "Tester",
|
||||||
|
"repository": "https://github.com/test/mock-ext",
|
||||||
|
"license": "MIT",
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"speckit_version": ">=0.1.0",
|
||||||
|
},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "speckit.mock-ext.hello",
|
||||||
|
"file": "commands/hello.md",
|
||||||
|
"description": "Test hello command",
|
||||||
|
"aliases": ["speckit.mock-ext.greet"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(manifest_data, f)
|
||||||
|
|
||||||
|
commands_dir = ext_dir / "commands"
|
||||||
|
commands_dir.mkdir()
|
||||||
|
|
||||||
|
# Command file with dotted speckit references in frontmatter and body
|
||||||
|
cmd_content = """---
|
||||||
|
description: "Test hello command"
|
||||||
|
agent: speckit.tasks
|
||||||
|
handoffs:
|
||||||
|
- agent: speckit.iterate.start
|
||||||
|
message: "Hand off to start"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Hello Command
|
||||||
|
|
||||||
|
Please refer to speckit.mock-ext.greet for instructions.
|
||||||
|
$ARGUMENTS
|
||||||
|
"""
|
||||||
|
(commands_dir / "hello.md").write_text(cmd_content, encoding="utf-8")
|
||||||
|
|
||||||
|
return project_dir, ext_dir, commands_dest_dir
|
||||||
|
|
||||||
|
def test_cline_extension_hyphenation(self, tmp_path):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from unittest.mock import patch
|
||||||
|
from specify_cli import app
|
||||||
|
from specify_cli.agents import CommandRegistrar
|
||||||
|
|
||||||
|
project_dir, ext_dir, cline_workflows_dir = self._setup_mock_extension(tmp_path, "cline")
|
||||||
|
|
||||||
|
# 3. Run specify extension add
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.object(Path, "cwd", return_value=project_dir):
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify CLI printed hyphenated commands
|
||||||
|
# Note: We assert that the primary command 'speckit-mock-ext-hello' is printed,
|
||||||
|
# but we do not assert that the alias 'speckit-mock-ext-greet' is printed in the console
|
||||||
|
# because manifest.commands only lists primary commands.
|
||||||
|
assert "speckit-mock-ext-hello" in result.output
|
||||||
|
assert "speckit.mock-ext.hello" not in result.output
|
||||||
|
|
||||||
|
# Verify on-disk command names are hyphenated
|
||||||
|
hello_file = cline_workflows_dir / "speckit-mock-ext-hello.md"
|
||||||
|
greet_file = cline_workflows_dir / "speckit-mock-ext-greet.md"
|
||||||
|
|
||||||
|
assert hello_file.exists()
|
||||||
|
assert greet_file.exists()
|
||||||
|
|
||||||
|
# Verify frontmatter in the generated files is recursively hyphenated
|
||||||
|
hello_text = hello_file.read_text(encoding="utf-8")
|
||||||
|
hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text)
|
||||||
|
assert hello_fm["agent"] == "speckit-tasks"
|
||||||
|
assert hello_fm["handoffs"][0]["agent"] == "speckit-iterate-start"
|
||||||
|
|
||||||
|
# Verify body references are hyphenated for Cline
|
||||||
|
assert "speckit-mock-ext-greet" in hello_body
|
||||||
|
assert "speckit.mock-ext.greet" not in hello_body
|
||||||
|
|
||||||
|
def test_non_cline_extension_no_hyphenation(self, tmp_path):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from unittest.mock import patch
|
||||||
|
from specify_cli import app
|
||||||
|
from specify_cli.agents import CommandRegistrar
|
||||||
|
|
||||||
|
project_dir, ext_dir, claude_commands_dir = self._setup_mock_extension(tmp_path, "claude")
|
||||||
|
|
||||||
|
# 3. Run specify extension add
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch.object(Path, "cwd", return_value=project_dir):
|
||||||
|
result = runner.invoke(
|
||||||
|
app, ["extension", "add", str(ext_dir), "--dev"], catch_exceptions=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify CLI printed dotted commands
|
||||||
|
# Note: We assert that the primary command 'speckit.mock-ext.hello' is printed,
|
||||||
|
# but we do not assert that the alias 'speckit.mock-ext.greet' is printed in the console
|
||||||
|
# because manifest.commands only lists primary commands.
|
||||||
|
assert "speckit.mock-ext.hello" in result.output
|
||||||
|
assert "speckit-mock-ext-hello" not in result.output
|
||||||
|
|
||||||
|
# Verify on-disk command names are dotted
|
||||||
|
hello_file = claude_commands_dir / "speckit.mock-ext.hello.md"
|
||||||
|
greet_file = claude_commands_dir / "speckit.mock-ext.greet.md"
|
||||||
|
|
||||||
|
assert hello_file.exists()
|
||||||
|
assert greet_file.exists()
|
||||||
|
|
||||||
|
# Verify frontmatter references are still dotted
|
||||||
|
hello_text = hello_file.read_text(encoding="utf-8")
|
||||||
|
hello_fm, hello_body = CommandRegistrar.parse_frontmatter(hello_text)
|
||||||
|
assert hello_fm["agent"] == "speckit.tasks"
|
||||||
|
assert hello_fm["handoffs"][0]["agent"] == "speckit.iterate.start"
|
||||||
|
|
||||||
|
# Verify body references are still dotted for non-Cline
|
||||||
|
assert "speckit.mock-ext.greet" in hello_body
|
||||||
|
assert "speckit-mock-ext-greet" not in hello_body
|
||||||
|
|||||||
@@ -2269,6 +2269,85 @@ class TestInitOptions:
|
|||||||
|
|
||||||
assert load_init_options(project_dir) == {}
|
assert load_init_options(project_dir) == {}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"value",
|
||||||
|
["名前-プロジェクト", "café-résumé", "Ωmega-Δelta", "🚀-launch"],
|
||||||
|
)
|
||||||
|
def test_save_load_round_trip_preserves_non_ascii(self, project_dir, value):
|
||||||
|
"""Non-ASCII values round-trip via explicit UTF-8 encoding.
|
||||||
|
|
||||||
|
``Path.write_text`` / ``Path.read_text`` default to the system
|
||||||
|
locale codec on Windows (cp1252 / gb2312 / cp932). Without
|
||||||
|
``encoding="utf-8"`` pinned on both ends, a project name like
|
||||||
|
``café`` written on a UTF-8 host becomes garbled or unreadable on
|
||||||
|
a cp1252 host (and vice versa). Pin UTF-8 explicitly so init
|
||||||
|
options round-trip across machines and CI.
|
||||||
|
|
||||||
|
Note: this test only meaningfully exercises the encoding pin
|
||||||
|
because ``save_init_options`` now writes JSON with
|
||||||
|
``ensure_ascii=False`` — otherwise ``json.dumps`` would output
|
||||||
|
ASCII-only ``\\uXXXX`` escapes and the encoding pin would be a
|
||||||
|
no-op for any value here. ``test_save_writes_real_utf8_bytes``
|
||||||
|
below asserts that contract directly.
|
||||||
|
"""
|
||||||
|
from specify_cli import save_init_options, load_init_options
|
||||||
|
|
||||||
|
save_init_options(project_dir, {"ai": "claude", "project_name": value})
|
||||||
|
|
||||||
|
loaded = load_init_options(project_dir)
|
||||||
|
assert loaded["project_name"] == value
|
||||||
|
|
||||||
|
def test_save_writes_real_utf8_bytes(self, project_dir):
|
||||||
|
"""The on-disk file contains real UTF-8 bytes, not ``\\uXXXX`` escapes.
|
||||||
|
|
||||||
|
Pinning ``encoding="utf-8"`` on ``write_text`` only makes a
|
||||||
|
difference when the serialiser actually emits non-ASCII
|
||||||
|
characters. With ``ensure_ascii=False`` on ``json.dumps`` the
|
||||||
|
non-ASCII bytes hit the file, so the encoding pin is the thing
|
||||||
|
that decides between cp1252 garbage and clean UTF-8 on Windows.
|
||||||
|
|
||||||
|
This test pins that behaviour: the on-disk bytes are valid UTF-8
|
||||||
|
and contain the multi-byte encoding of ``café``, not its
|
||||||
|
``\\u00e9`` escape form. Reviewers can verify that removing
|
||||||
|
``ensure_ascii=False`` or ``encoding="utf-8"`` from the writer
|
||||||
|
breaks this test, which is what Copilot's review pointed out the
|
||||||
|
original round-trip test failed to do.
|
||||||
|
"""
|
||||||
|
from specify_cli import save_init_options
|
||||||
|
|
||||||
|
save_init_options(project_dir, {"project_name": "café"})
|
||||||
|
|
||||||
|
opts_file = project_dir / ".specify" / "init-options.json"
|
||||||
|
raw = opts_file.read_bytes()
|
||||||
|
# 'café' in UTF-8 ends with bytes 0xC3 0xA9 ('é'). The cp1252
|
||||||
|
# encoding of 'é' is the single byte 0xE9. The JSON-escape form
|
||||||
|
# would be the 6-byte literal '\\u00e9'. We assert the UTF-8 form
|
||||||
|
# is present so the test pins the actual contract.
|
||||||
|
assert b"caf\xc3\xa9" in raw, (
|
||||||
|
"Expected UTF-8 bytes for 'café' in the on-disk file, "
|
||||||
|
f"got: {raw!r}"
|
||||||
|
)
|
||||||
|
# And the whole file decodes cleanly as UTF-8.
|
||||||
|
raw.decode("utf-8")
|
||||||
|
|
||||||
|
def test_load_returns_empty_on_locale_corrupted_file(self, project_dir):
|
||||||
|
"""A file written in a non-UTF-8 codec falls back to {}, not crash.
|
||||||
|
|
||||||
|
Simulates a file produced by an old client (or by a peer machine
|
||||||
|
with a different default locale) that contains bytes invalid as
|
||||||
|
UTF-8. ``load_init_options`` should fall back to ``{}`` per the
|
||||||
|
existing contract — never propagate a raw ``UnicodeDecodeError``
|
||||||
|
to the CLI surface.
|
||||||
|
"""
|
||||||
|
from specify_cli import load_init_options
|
||||||
|
|
||||||
|
opts_file = project_dir / ".specify" / "init-options.json"
|
||||||
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# 0xE9 is 'é' in cp1252 but an invalid lead byte in UTF-8.
|
||||||
|
opts_file.write_bytes(b'{"project_name": "caf\xe9"}')
|
||||||
|
|
||||||
|
assert load_init_options(project_dir) == {}
|
||||||
|
|
||||||
|
|
||||||
class TestPresetSkills:
|
class TestPresetSkills:
|
||||||
"""Tests for preset skill registration and unregistration.
|
"""Tests for preset skill registration and unregistration.
|
||||||
|
|||||||
@@ -520,6 +520,7 @@ class TestCommandStep:
|
|||||||
assert result.output["integration"] == "gemini"
|
assert result.output["integration"] == "gemini"
|
||||||
|
|
||||||
def test_step_override_model(self):
|
def test_step_override_model(self):
|
||||||
|
from unittest.mock import patch
|
||||||
from specify_cli.workflows.steps.command import CommandStep
|
from specify_cli.workflows.steps.command import CommandStep
|
||||||
from specify_cli.workflows.base import StepContext
|
from specify_cli.workflows.base import StepContext
|
||||||
|
|
||||||
@@ -531,10 +532,12 @@ class TestCommandStep:
|
|||||||
"model": "opus-4",
|
"model": "opus-4",
|
||||||
"input": {},
|
"input": {},
|
||||||
}
|
}
|
||||||
result = step.execute(config, ctx)
|
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
|
||||||
|
result = step.execute(config, ctx)
|
||||||
assert result.output["model"] == "opus-4"
|
assert result.output["model"] == "opus-4"
|
||||||
|
|
||||||
def test_options_merge(self):
|
def test_options_merge(self):
|
||||||
|
from unittest.mock import patch
|
||||||
from specify_cli.workflows.steps.command import CommandStep
|
from specify_cli.workflows.steps.command import CommandStep
|
||||||
from specify_cli.workflows.base import StepContext
|
from specify_cli.workflows.base import StepContext
|
||||||
|
|
||||||
@@ -546,7 +549,8 @@ class TestCommandStep:
|
|||||||
"options": {"thinking-budget": 32768},
|
"options": {"thinking-budget": 32768},
|
||||||
"input": {},
|
"input": {},
|
||||||
}
|
}
|
||||||
result = step.execute(config, ctx)
|
with patch("specify_cli.workflows.steps.command.shutil.which", return_value=None):
|
||||||
|
result = step.execute(config, ctx)
|
||||||
assert result.output["options"]["max-tokens"] == 8000
|
assert result.output["options"]["max-tokens"] == 8000
|
||||||
assert result.output["options"]["thinking-budget"] == 32768
|
assert result.output["options"]["thinking-budget"] == 32768
|
||||||
|
|
||||||
@@ -2375,6 +2379,306 @@ steps:
|
|||||||
assert state.step_results["stamp"]["output"]["stdout"].strip() == "explicit-456"
|
assert state.step_results["stamp"]["output"]["stdout"].strip() == "explicit-456"
|
||||||
|
|
||||||
|
|
||||||
|
# ===== continue_on_error Tests =====
|
||||||
|
#
|
||||||
|
# Locks the contract documented in workflows/README.md "Error Handling"
|
||||||
|
# section: when a step returns `StepResult(status=StepStatus.FAILED, ...)` and
|
||||||
|
# `continue_on_error: true` is declared, the engine records the step's
|
||||||
|
# `output` (with `exit_code` and `stderr` from the failure) and its
|
||||||
|
# `status` (sibling key on `steps.<id>`, not nested under `output`)
|
||||||
|
# and continues to the next sibling step instead of halting the run.
|
||||||
|
# Gate aborts (`output.aborted`) still halt regardless of the flag.
|
||||||
|
# Unhandled exceptions raised out of `step_impl.execute()` are out of
|
||||||
|
# scope for this flag — they propagate to `WorkflowEngine.execute()`
|
||||||
|
# and abort the run.
|
||||||
|
|
||||||
|
|
||||||
|
class TestContinueOnError:
|
||||||
|
"""Test the `continue_on_error` step-level field."""
|
||||||
|
|
||||||
|
def test_undeclared_failure_halts_run(self, project_dir):
|
||||||
|
"""Default behaviour (no `continue_on_error`): a failing step
|
||||||
|
halts the workflow run with `status == StepStatus.FAILED`.
|
||||||
|
|
||||||
|
Locks the byte-equivalent default — workflows that do not
|
||||||
|
declare the flag must behave exactly as before this feature.
|
||||||
|
"""
|
||||||
|
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||||
|
from specify_cli.workflows.base import RunStatus
|
||||||
|
|
||||||
|
definition = WorkflowDefinition.from_string("""
|
||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "halt-on-fail"
|
||||||
|
name: "Halt On Fail"
|
||||||
|
version: "1.0.0"
|
||||||
|
steps:
|
||||||
|
- id: fail-step
|
||||||
|
type: shell
|
||||||
|
run: "exit 7"
|
||||||
|
- id: after
|
||||||
|
type: shell
|
||||||
|
run: "echo should-not-run"
|
||||||
|
""")
|
||||||
|
engine = WorkflowEngine(project_dir)
|
||||||
|
state = engine.execute(definition)
|
||||||
|
|
||||||
|
assert state.status == RunStatus.FAILED
|
||||||
|
assert "fail-step" in state.step_results
|
||||||
|
assert state.step_results["fail-step"]["output"]["exit_code"] == 7
|
||||||
|
# Subsequent step never executes when the flag is absent.
|
||||||
|
assert "after" not in state.step_results
|
||||||
|
|
||||||
|
def test_declared_and_fired_continues_run(self, project_dir):
|
||||||
|
"""`continue_on_error: true` + failing step: the run keeps
|
||||||
|
going, the failed step's result is recorded, and the
|
||||||
|
downstream step runs.
|
||||||
|
"""
|
||||||
|
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||||
|
from specify_cli.workflows.base import RunStatus
|
||||||
|
|
||||||
|
definition = WorkflowDefinition.from_string("""
|
||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "continue-past-fail"
|
||||||
|
name: "Continue Past Fail"
|
||||||
|
version: "1.0.0"
|
||||||
|
steps:
|
||||||
|
- id: flaky-step
|
||||||
|
type: shell
|
||||||
|
run: "exit 42"
|
||||||
|
continue_on_error: true
|
||||||
|
- id: after
|
||||||
|
type: shell
|
||||||
|
run: "echo did-run"
|
||||||
|
""")
|
||||||
|
engine = WorkflowEngine(project_dir)
|
||||||
|
state = engine.execute(definition)
|
||||||
|
|
||||||
|
assert state.status == RunStatus.COMPLETED
|
||||||
|
# Failed step's exit_code is preserved so downstream branching
|
||||||
|
# can inspect it.
|
||||||
|
assert state.step_results["flaky-step"]["output"]["exit_code"] == 42
|
||||||
|
assert state.step_results["flaky-step"]["status"] == "failed"
|
||||||
|
# Downstream step ran successfully.
|
||||||
|
assert state.step_results["after"]["output"]["exit_code"] == 0
|
||||||
|
|
||||||
|
def test_declared_but_step_succeeded_is_noop(self, project_dir):
|
||||||
|
"""`continue_on_error: true` on a step that succeeds is a
|
||||||
|
no-op — the flag only changes behaviour on StepStatus.FAILED status.
|
||||||
|
"""
|
||||||
|
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||||
|
from specify_cli.workflows.base import RunStatus
|
||||||
|
|
||||||
|
definition = WorkflowDefinition.from_string("""
|
||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "flag-but-success"
|
||||||
|
name: "Flag But Success"
|
||||||
|
version: "1.0.0"
|
||||||
|
steps:
|
||||||
|
- id: ok-step
|
||||||
|
type: shell
|
||||||
|
run: "echo ok"
|
||||||
|
continue_on_error: true
|
||||||
|
- id: after
|
||||||
|
type: shell
|
||||||
|
run: "echo done"
|
||||||
|
""")
|
||||||
|
engine = WorkflowEngine(project_dir)
|
||||||
|
state = engine.execute(definition)
|
||||||
|
|
||||||
|
assert state.status == RunStatus.COMPLETED
|
||||||
|
assert state.step_results["ok-step"]["status"] == "completed"
|
||||||
|
assert state.step_results["ok-step"]["output"]["exit_code"] == 0
|
||||||
|
assert state.step_results["after"]["output"]["exit_code"] == 0
|
||||||
|
|
||||||
|
def test_if_branch_routes_around_failure(self, project_dir):
|
||||||
|
"""End-to-end: `continue_on_error` + `if` cleanly routes around
|
||||||
|
a failure. The recovery branch runs; the success branch does
|
||||||
|
not.
|
||||||
|
|
||||||
|
Mirrors the canonical usage pattern from the original feature
|
||||||
|
discussion in issue #2591.
|
||||||
|
"""
|
||||||
|
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||||
|
from specify_cli.workflows.base import RunStatus
|
||||||
|
|
||||||
|
definition = WorkflowDefinition.from_string("""
|
||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "route-around"
|
||||||
|
name: "Route Around Failure"
|
||||||
|
version: "1.0.0"
|
||||||
|
steps:
|
||||||
|
- id: heavy-thing
|
||||||
|
type: shell
|
||||||
|
run: "exit 1"
|
||||||
|
continue_on_error: true
|
||||||
|
- id: check-result
|
||||||
|
type: if
|
||||||
|
condition: "{{ steps.heavy-thing.output.exit_code != 0 }}"
|
||||||
|
then:
|
||||||
|
- id: recovery
|
||||||
|
type: shell
|
||||||
|
run: "echo recovery-ran"
|
||||||
|
else:
|
||||||
|
- id: happy-path
|
||||||
|
type: shell
|
||||||
|
run: "echo happy-path-ran"
|
||||||
|
""")
|
||||||
|
engine = WorkflowEngine(project_dir)
|
||||||
|
state = engine.execute(definition)
|
||||||
|
|
||||||
|
assert state.status == RunStatus.COMPLETED
|
||||||
|
assert "recovery" in state.step_results
|
||||||
|
assert "happy-path" not in state.step_results
|
||||||
|
|
||||||
|
def test_gate_abort_still_halts_with_continue_on_error(
|
||||||
|
self, project_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""`continue_on_error` does NOT override a deliberate gate
|
||||||
|
abort. `output.aborted` always halts the run with
|
||||||
|
`status == ABORTED`.
|
||||||
|
|
||||||
|
Aborts are explicit operator decisions; continue_on_error
|
||||||
|
is for transient/expected step failures only.
|
||||||
|
"""
|
||||||
|
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||||
|
from specify_cli.workflows.base import RunStatus
|
||||||
|
from specify_cli.workflows.steps.gate import GateStep
|
||||||
|
from specify_cli.workflows.steps import gate as gate_module
|
||||||
|
|
||||||
|
# Force the gate step into interactive mode and feed a "reject"
|
||||||
|
# choice so the abort path actually runs in the test env
|
||||||
|
# (default behaviour returns StepStatus.PAUSED when stdin is not a TTY).
|
||||||
|
# Swap sys.stdin itself for a stub: setattr on the real
|
||||||
|
# TextIOWrapper's `isatty` method is not assignable under some
|
||||||
|
# runners (e.g. pytest with capture disabled).
|
||||||
|
class _TTYStdin:
|
||||||
|
def isatty(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(gate_module.sys, "stdin", _TTYStdin())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
GateStep, "_prompt", staticmethod(lambda _msg, _opts: "reject")
|
||||||
|
)
|
||||||
|
|
||||||
|
definition = WorkflowDefinition.from_string("""
|
||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "gate-abort-halts"
|
||||||
|
name: "Gate Abort Halts"
|
||||||
|
version: "1.0.0"
|
||||||
|
steps:
|
||||||
|
- id: gate-step
|
||||||
|
type: gate
|
||||||
|
message: "Approve?"
|
||||||
|
options: [approve, reject]
|
||||||
|
on_reject: abort
|
||||||
|
continue_on_error: true
|
||||||
|
- id: should-not-run
|
||||||
|
type: shell
|
||||||
|
run: "echo nope"
|
||||||
|
""")
|
||||||
|
engine = WorkflowEngine(project_dir)
|
||||||
|
state = engine.execute(definition)
|
||||||
|
|
||||||
|
assert state.status == RunStatus.ABORTED
|
||||||
|
assert "should-not-run" not in state.step_results
|
||||||
|
|
||||||
|
def test_validation_rejects_non_bool_continue_on_error(self):
|
||||||
|
"""`continue_on_error` must be a literal boolean; coerced
|
||||||
|
strings like `"true"` are rejected at validation time so
|
||||||
|
authoring mistakes surface before execution.
|
||||||
|
"""
|
||||||
|
from specify_cli.workflows.engine import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
validate_workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
definition = WorkflowDefinition.from_string("""
|
||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "bad-coe"
|
||||||
|
name: "Bad COE"
|
||||||
|
version: "1.0.0"
|
||||||
|
steps:
|
||||||
|
- id: step-one
|
||||||
|
type: shell
|
||||||
|
run: "true"
|
||||||
|
continue_on_error: "true"
|
||||||
|
""")
|
||||||
|
errors = validate_workflow(definition)
|
||||||
|
assert any(
|
||||||
|
"continue_on_error" in e and "boolean" in e for e in errors
|
||||||
|
), errors
|
||||||
|
|
||||||
|
def test_validation_accepts_bool_continue_on_error(self):
|
||||||
|
"""Boolean values pass validation cleanly."""
|
||||||
|
from specify_cli.workflows.engine import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
validate_workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
for value in (True, False):
|
||||||
|
yaml_value = "true" if value else "false"
|
||||||
|
definition = WorkflowDefinition.from_string(f"""
|
||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "good-coe"
|
||||||
|
name: "Good COE"
|
||||||
|
version: "1.0.0"
|
||||||
|
steps:
|
||||||
|
- id: step-one
|
||||||
|
type: shell
|
||||||
|
run: "true"
|
||||||
|
continue_on_error: {yaml_value}
|
||||||
|
""")
|
||||||
|
errors = validate_workflow(definition)
|
||||||
|
assert errors == [], errors
|
||||||
|
|
||||||
|
def test_engine_ignores_truthy_non_bool_continue_on_error(self, project_dir):
|
||||||
|
"""Defense-in-depth: even if a caller bypasses
|
||||||
|
`validate_workflow()` and feeds the engine a definition with
|
||||||
|
`continue_on_error: "true"` (a string), the engine must NOT
|
||||||
|
honour the flag — only a literal boolean enables the
|
||||||
|
behaviour. `WorkflowEngine.execute()` does not auto-validate
|
||||||
|
(the `WorkflowEngine.load_workflow` docstring explicitly
|
||||||
|
notes the definition is "not yet validated; call
|
||||||
|
`validate_workflow()` or `engine.validate()` separately"),
|
||||||
|
so the engine guards against truthy non-bool values itself
|
||||||
|
via an identity check rather than truthiness.
|
||||||
|
"""
|
||||||
|
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||||
|
from specify_cli.workflows.base import RunStatus
|
||||||
|
|
||||||
|
# Bypass `validate_workflow()` — execute() is what would
|
||||||
|
# be called by a caller that skipped validation.
|
||||||
|
definition = WorkflowDefinition.from_string("""
|
||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "string-coe"
|
||||||
|
name: "String COE"
|
||||||
|
version: "1.0.0"
|
||||||
|
steps:
|
||||||
|
- id: fail-step
|
||||||
|
type: shell
|
||||||
|
run: "exit 1"
|
||||||
|
continue_on_error: "true"
|
||||||
|
- id: should-not-run
|
||||||
|
type: shell
|
||||||
|
run: "echo should-not-run"
|
||||||
|
""")
|
||||||
|
engine = WorkflowEngine(project_dir)
|
||||||
|
state = engine.execute(definition)
|
||||||
|
|
||||||
|
# String "true" is truthy but not a literal boolean, so the
|
||||||
|
# engine must treat the step as a halting failure.
|
||||||
|
assert state.status == RunStatus.FAILED
|
||||||
|
assert "should-not-run" not in state.step_results
|
||||||
|
|
||||||
|
|
||||||
# ===== State Persistence Tests =====
|
# ===== State Persistence Tests =====
|
||||||
|
|
||||||
class TestRunState:
|
class TestRunState:
|
||||||
|
|||||||
@@ -219,6 +219,83 @@ Aggregate results from fan-out steps:
|
|||||||
output: {}
|
output: {}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
By default, any step that returns `StepResult(status=StepStatus.FAILED, ...)`
|
||||||
|
at runtime halts the entire run — most commonly a `shell` or
|
||||||
|
`command` step exiting non-zero. Set `continue_on_error: true` on
|
||||||
|
a step to record its result and continue to the next sibling step
|
||||||
|
instead. When the failure was a non-zero exit, the exit code
|
||||||
|
remains available on `steps.<id>.output.exit_code` so a downstream
|
||||||
|
`if` or `switch` can branch on it (or a `gate` can surface it to
|
||||||
|
the operator via `{{ }}` interpolation in `message`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: heavy-thing
|
||||||
|
type: command
|
||||||
|
integration: claude
|
||||||
|
command: speckit.heavy-thing
|
||||||
|
continue_on_error: true
|
||||||
|
|
||||||
|
- id: check-result
|
||||||
|
type: if
|
||||||
|
condition: "{{ steps.heavy-thing.output.exit_code != 0 }}"
|
||||||
|
then:
|
||||||
|
- id: review
|
||||||
|
type: gate
|
||||||
|
message: "Step failed (exit {{ steps.heavy-thing.output.exit_code }}). Approve to run the recovery path, or reject to leave the failure recorded and move on."
|
||||||
|
on_reject: skip
|
||||||
|
- id: recover
|
||||||
|
type: if
|
||||||
|
condition: "{{ steps.review.output.choice == 'approve' }}"
|
||||||
|
then:
|
||||||
|
- id: rerun
|
||||||
|
command: speckit.recovery
|
||||||
|
else:
|
||||||
|
- id: next-thing
|
||||||
|
command: speckit.next-thing
|
||||||
|
```
|
||||||
|
|
||||||
|
A few things worth knowing about that example:
|
||||||
|
|
||||||
|
- Both gate options (`approve`, `reject`) return `StepStatus.COMPLETED`;
|
||||||
|
`on_reject: skip` controls only whether the engine aborts on reject
|
||||||
|
(it doesn't, with `skip`) — it does **not** auto-skip subsequent
|
||||||
|
sibling steps in the `then:` list. Downstream branching is the
|
||||||
|
workflow author's responsibility: read
|
||||||
|
`{{ steps.<gate-id>.output.choice }}` in a follow-up `if`, `switch`,
|
||||||
|
or expression, as the `recover` step above does.
|
||||||
|
- `on_reject` has three values: `abort` (default — reject → `StepStatus.FAILED`
|
||||||
|
with `output.aborted = True`, halts the run), `skip` (reject →
|
||||||
|
`StepStatus.COMPLETED`, author handles branching as shown), and `retry`
|
||||||
|
(reject → `StepStatus.PAUSED` so the next `specify workflow resume` re-runs
|
||||||
|
the gate).
|
||||||
|
- Gates do not automatically re-run the failed step. To express a
|
||||||
|
retry path, either define custom gate options and branch on the
|
||||||
|
choice downstream, or wrap the failing step in your own loop.
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- The field must be a literal boolean (`true` / `false`); coerced
|
||||||
|
strings like `"true"` are rejected at validation time.
|
||||||
|
- **Scope: returned failures only.** The flag applies to step results
|
||||||
|
with `status=StepStatus.FAILED`. Unhandled exceptions raised out of a step's
|
||||||
|
`execute()` method are caught one level up by `WorkflowEngine.execute()`,
|
||||||
|
logged as `workflow_failed`, and abort the run regardless of
|
||||||
|
`continue_on_error`. If a step author wants the flag to cover an
|
||||||
|
exceptional path, the step must catch the exception internally and
|
||||||
|
return `StepResult(status=StepStatus.FAILED, ...)` with the failure encoded in
|
||||||
|
`output` (e.g. `exit_code`, `stderr`, or a custom field).
|
||||||
|
- Gate aborts (`on_reject: abort` chosen by the operator) always halt
|
||||||
|
the run — `continue_on_error` does not override them. The flag is
|
||||||
|
for transient/expected step failures, not for overriding deliberate
|
||||||
|
operator decisions.
|
||||||
|
- Structural validation runs up-front: `specify workflow run` rejects
|
||||||
|
invalid workflow definitions before the run is created, so
|
||||||
|
validation failures never reach this code path.
|
||||||
|
- When the flag is omitted, behaviour is byte-equivalent to before
|
||||||
|
this feature.
|
||||||
|
|
||||||
## Expressions
|
## Expressions
|
||||||
|
|
||||||
Workflow definitions use `{{ expression }}` syntax for dynamic values:
|
Workflow definitions use `{{ expression }}` syntax for dynamic values:
|
||||||
|
|||||||
Reference in New Issue
Block a user