mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e28d5caa14 | ||
|
|
d982c2f67f | ||
|
|
e8ade110da | ||
|
|
876e532d76 | ||
|
|
b4a0f8b564 | ||
|
|
2d56dfd73d | ||
|
|
810d6fcfe1 | ||
|
|
36501d459f | ||
|
|
c5ac90b245 | ||
|
|
3571ba72d8 | ||
|
|
6fb7e77b3e | ||
|
|
5e72b1d486 |
@@ -48,8 +48,6 @@
|
||||
"openai.chatgpt",
|
||||
// Kilo Code
|
||||
"kilocode.Kilo-Code",
|
||||
// Roo Code
|
||||
"RooVeterinaryInc.roo-cline",
|
||||
// Claude Code
|
||||
"anthropic.claude-code"
|
||||
],
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -89,7 +89,6 @@ body:
|
||||
- Pi Coding Agent
|
||||
- Qoder CLI
|
||||
- Qwen Code
|
||||
- Roo Code
|
||||
- RovoDev ACLI
|
||||
- SHAI
|
||||
- Tabnine CLI
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -83,7 +83,6 @@ body:
|
||||
- Pi Coding Agent
|
||||
- Qoder CLI
|
||||
- Qwen Code
|
||||
- Roo Code
|
||||
- RovoDev ACLI
|
||||
- SHAI
|
||||
- Tabnine CLI
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -2,6 +2,38 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.3] - 2026-07-01
|
||||
|
||||
### Changed
|
||||
|
||||
- feat(copilot): warn before skills default rollout (#3256)
|
||||
- Add June 2026 newsletter (#3289)
|
||||
- docs(toc): add Bundles and Authentication to the Reference nav (#3267)
|
||||
- fix(integrations): add zed to discovery catalog.json (#3266)
|
||||
- fix(integrations): cline hook note collapses onto instruction at EOF (#3263)
|
||||
- refactor: move workflow command handlers to workflows/_commands.py (PR-8/8) (#3159)
|
||||
- chore: retire Roo Code integration — extension shut down (#3167) (#3212)
|
||||
- fix(bundle): allow 'catalog remove' by the same relative path used to add (#3242)
|
||||
- fix(workflows): reject bool max_iterations in while/do-while validation (#3237)
|
||||
- fix: allow prerelease spec-kit versions in compatibility checks (#2695)
|
||||
- chore: release 0.12.2, begin 0.12.3.dev0 development (#3259)
|
||||
|
||||
## [0.12.2] - 2026-06-30
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(scripts): portable uppercase for branch-name acronym retention (bash 3.2) (#3192)
|
||||
- chore: retire Windsurf integration — absorbed into Cognition Devin (#3168) (#3213)
|
||||
- [extension] Update Intake extension to v0.1.3 (#3254)
|
||||
- feat(workflows): honor max_concurrency in fan-out via a bounded thread pool (#3224)
|
||||
- Update Architecture Workflow extension to v1.2.2 (#3255)
|
||||
- Add Repository Governance extension to community catalog (#3252)
|
||||
- Update Workflow Preset to v1.3.11 (#3251)
|
||||
- chore: retire iflow integration — product discontinued (#3166) (#3211)
|
||||
- docs(codebuddy): fix dead install links and CodeBuddy capitalization (#3172) (#3216)
|
||||
- fix: reject host-less catalog URLs in base and preset validators (#3209) (#3227)
|
||||
- chore: release 0.12.1, begin 0.12.2.dev0 development (#3253)
|
||||
|
||||
## [0.12.1] - 2026-06-30
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -33,7 +33,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
|
||||
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
|
||||
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | |
|
||||
| [Roo Code](https://roocode.com/) | `roo` | |
|
||||
| [RovoDev](https://www.atlassian.com/software/rovo-dev) | `rovodev` | Generates `.rovodev/skills/`, prompt wrappers, and `prompts.yml`; runtime dispatch uses `acli rovodev` |
|
||||
| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | |
|
||||
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | |
|
||||
@@ -267,7 +266,6 @@ The currently declared multi-install safe integrations are:
|
||||
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
|
||||
| `qodercli` | `.qoder/commands`, `QODER.md` |
|
||||
| `qwen` | `.qwen/commands`, `QWEN.md` |
|
||||
| `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` |
|
||||
| `shai` | `.shai/commands`, `SHAI.md` |
|
||||
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
|
||||
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
href: reference/presets.md
|
||||
- name: Workflows
|
||||
href: reference/workflows.md
|
||||
- name: Bundles
|
||||
href: reference/bundles.md
|
||||
- name: Authentication
|
||||
href: reference/authentication.md
|
||||
|
||||
# Concepts
|
||||
- name: Concepts
|
||||
|
||||
@@ -185,7 +185,7 @@ cp -r .specify/scripts .specify/scripts-backup
|
||||
|
||||
### 3. Duplicate slash commands (IDE-based agents)
|
||||
|
||||
Some IDE-based agents (like Kilo Code, Roo Code) may show **duplicate slash commands** after upgrading—both old and new versions appear.
|
||||
Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash commands** after upgrading—both old and new versions appear.
|
||||
|
||||
**Solution:** Manually delete the old command files from your agent's folder.
|
||||
|
||||
@@ -242,7 +242,7 @@ mv /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
|
||||
### Scenario 3: "I see duplicate slash commands in my IDE"
|
||||
|
||||
This happens with IDE-based agents (Kilo Code, Roo Code, Cline, etc.).
|
||||
This happens with IDE-based agents (Kilo Code, Cline, etc.).
|
||||
|
||||
```bash
|
||||
# Find the agent folder (example: .kilocode/workflows/)
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"pi": "AGENTS.md",
|
||||
"qodercli": "QODER.md",
|
||||
"qwen": "QWEN.md",
|
||||
"roo": ".roo/rules/specify-rules.md",
|
||||
"rovodev": "AGENTS.md",
|
||||
"shai": "SHAI.md",
|
||||
"tabnine": "TABNINE.md",
|
||||
|
||||
@@ -165,15 +165,6 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide"]
|
||||
},
|
||||
"roo": {
|
||||
"id": "roo",
|
||||
"name": "Roo Code",
|
||||
"version": "1.0.0",
|
||||
"description": "Roo Code IDE integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide"]
|
||||
},
|
||||
"rovodev": {
|
||||
"id": "rovodev",
|
||||
"name": "RovoDev ACLI",
|
||||
@@ -308,6 +299,15 @@
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli", "skills", "z-ai"]
|
||||
},
|
||||
"zed": {
|
||||
"id": "zed",
|
||||
"name": "Zed",
|
||||
"version": "1.0.0",
|
||||
"description": "Zed editor skills-based integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide", "skills"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
156
newsletters/2026-June.md
Normal file
156
newsletters/2026-June.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Spec Kit - June 2026 Newsletter
|
||||
|
||||
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June was the highest-volume press month on record — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and 75 substantive articles appeared across 25+ languages. A summary is in the table below, followed by details.
|
||||
|
||||
| **Spec Kit Core (Jun 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. June was the highest-volume press month yet — **75 substantive articles** across 25+ languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
|
||||
|
||||
***
|
||||
|
||||
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. A record **75 substantive articles** appeared in more than 25 languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
|
||||
|
||||
## Spec Kit Project Updates
|
||||
|
||||
### Releases Overview
|
||||
|
||||
**v0.9.0–v0.9.5** (June 1–5) opened the month with a minor bump and five patches. The headline was **native Cline integration** (#2508) and **rovodev** support (#2539), plus the long-running effort to extract agent-context updates into a bundled, opt-in **`agent-context` extension** (#2546, closing #2398). The CLI gained **`specify self upgrade`** (#2475) and a **`--force` flag for `extension add`** (#2530). The workflow engine picked up four capabilities: running YAML files **without a project** (#2825), accepting **updated inputs on resume** (#2815), **structured JSON output** across `run`/`resume`/`status` (#2814), and a **`continue_on_error` step field** for non-halting failures (#2663). Windows compatibility hardened with UTF-8 stdout/stderr (#2817), and cursor-agent headless dispatch now works end-to-end (#2631). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.10.0–v0.10.4** (June 9–16) delivered the month's first real **breaking change**: the **git extension is now opt-in** and the long-deprecated `--no-git` flag was removed at v0.10.0 (#2873, closing #2168). A long-standing community ask landed as **per-event hook lists with priority ordering** (#2798, closing #2378), letting extensions cleanly compose multiple hooks on one event. Operators gained a **`specify integration status`** reporting command (#2674), and the extension schema picked up first-class **`category` and `effect` fields** (#2899) to natively express the `Candidate`/`Adjacent`/`Niche`/`Bridge` signals. Security-relevant fixes hardened **preset URL installs against unsafe redirects** (#2911) and preserved the Claude `SKILL.md` `argument-hint` for extension commands (#2916). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.11.0–v0.11.10** (June 16–29) was the largest release cluster of the month and centered on **workflows** and the new **convergence loop**. The **`/speckit.converge` command** shipped (#3001), and the **workflow step catalog** made workflow steps community-installable the way extensions and presets already are (#2394, closing #2216). A complementary **`init` workflow step** (#2838) lets a workflow bootstrap a project the way `specify init` does. Workflow execution became programmable: opt-in `output_format: json` exposes parsed shell stdout as `output.data` (#2963), and a new **`from_json` expression filter** (#2961) turns step outputs into typed values. The new **`bug-assess` agentic workflow** (#3023) automates bug triage from labeled issues, **Zed** joined the supported agents (#2780), and contributors gained an **integration scaffolder** (#2685). The **`specify bundle` command** made its debut here (#3070). Two Windows/PowerShell pain points closed — `specify init` no longer hangs on PowerShell 5.1 (#2938) and the 233-day-old worktree branch-numbering bug was fixed (#3054, closing #1066). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.12.0–v0.12.2** (June 29–30) closed the month with a minor bump making the **`agent-context` extension a full opt-in** (#3097) and a run of workflow-engine hardening: `max_concurrency` is now honored in fan-out via a bounded thread pool (#3224), gate validation no longer crashes on non-string options (#3233), pipe-filter detection became quote-aware (#3232), and a fan-in `wait_for` that names an unknown step is now rejected at validation (#3225). Three agents were also rationalized — **Firebender** (Android Studio / IntelliJ, #3077, closing #1548), **ZCode** (Z.AI, #3063), and **omp** (#3107) joined earlier in the run, while **Windsurf** was absorbed into Cognition Devin (#3168) and **iflow** was retired as discontinued (#3166). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Convergence Loop: `/speckit.converge`
|
||||
|
||||
The most significant addition to the SDD workflow since the core commands themselves, **`/speckit.converge`** (#3001) adds a ninth step that runs *after* `/speckit.implement` and answers the single most-cited concern in every review of the project: *does the code actually match the spec?*
|
||||
|
||||
Converge reads `spec.md`, `plan.md`, and `tasks.md` as the **sole source of intent** — with the constitution as governing constraints — assesses the current state of the code, and appends any remaining unbuilt work as new, traceable tasks. It is deliberately **not** a diff or git tool: it evaluates the *present* state of the code relative to the feature's artifacts, with no branch comparison and no history. Findings are classified by **gap type** — `missing` (absent entirely), `partial` (present but incomplete), `contradicts` (conflicts with intent or a constitution MUST), or `unrequested` (work the spec never called for) — and graded by severity, with a constitution-MUST violation always the highest.
|
||||
|
||||
Its defining design choice is that it is **append-only and never rewrites**. Its only write is a new `## Phase N: Convergence` section at the bottom of `tasks.md`; it never modifies the spec or plan, never renumbers existing tasks, and never touches application code — completing the appended tasks remains the job of `/speckit.implement`. When the codebase already satisfies everything, it leaves `tasks.md` byte-for-byte unchanged and simply reports **"✅ Converged."** Each appended task carries a `source-ref` (e.g. `FR-003`, `SC-002`, `US1/AC2`, a plan decision, or a constitution article), preserving traceability from requirement to remediation.
|
||||
|
||||
The result is an **iterative convergence loop** — converge → implement → converge — that runs until no gaps remain. It also smooths migration from OpenSpec by giving Spec Kit a first-class verify-and-close-the-gap step (#2673), directly answering the drift-and-verification demand the community had been expressing through extensions like Architecture Guard, Spec Trace, and the various drift-control tools. The command is now documented in the quickstart and the evolving-specs guide. [\[github.com\]](https://github.com/github/spec-kit/blob/main/docs/quickstart.md)
|
||||
|
||||
### The Bundle Subsystem: `specify bundle`
|
||||
|
||||
June's second headline was the debut of **bundles** (#3070), a distribution and composition layer that sits above the existing primitives. Where extensions, presets, workflows, and steps are the building blocks, a **bundle is a curated, versioned, role-based stack** that declares everything a team or role needs and installs it in a single step. Crucially, a bundle adds *no new runtime behavior of its own* — it composes what already exists through each component's own machinery, so there is nothing new to learn at execution time.
|
||||
|
||||
A bundle is described by a **`bundle.yml` manifest**: metadata (`id`, `name`, `version`, `role`, `author`, `license`), a `requires` block (minimum `speckit_version`, tools, MCP servers), and a `provides` block listing the exact extensions, presets (with `priority` and composition `strategy`), steps, and workflows it installs — each pinned to a version. The first example bundles ship four roles: **developer, product-manager, business-analyst, and security-researcher**.
|
||||
|
||||
The subcommand surface is a full package-manager experience: `search` and `info` (which previews the **fully expanded component set** with pinned versions and a `verified`-vs-`community` trust indicator before you install), `install`, `update` (`--all`), `remove`, `list`, `init`, `validate`, `build` (produces a single versioned `.zip` artifact), `publish`, and `catalog` management (`list`/`add`/`remove` sources). Installs are **idempotent with full provenance tracking**, so a bundle can be cleanly removed or refreshed later; `remove` uninstalls only the components a bundle contributed, leaving anything another installed bundle still needs in place. If run in a directory that isn't yet a Spec Kit project, `install` and `init` **bootstrap one first**, so a fresh checkout reaches a working state in a single command. The only cross-bundle conflict point checked at install time is the active integration.
|
||||
|
||||
Bundles are discovered through the same priority-ordered catalog stack (project, user, and built-in scopes) as every other component, and by the end of the month they had become a **fourth community-submittable artifact type** alongside extensions, presets, and workflows, via a dedicated submission path (#3162). Bundles are the project's answer to the "how do I distribute a whole role setup?" question — the composability story that ties the entire catalog together. [\[github.com\]](https://github.com/github/spec-kit/blob/main/docs/reference/bundles.md)
|
||||
|
||||
### The Workflow Engine Matures
|
||||
|
||||
Beyond converge and bundles, June was the month the **workflow engine grew up**. The **step catalog** (#2394) made steps community-distributable; the **`init` step** (#2838) let workflows bootstrap projects; **JSON output** (#2963) and the **`from_json` filter** (#2961) made step outputs consumable as typed data; and the **`bug-assess`** agentic workflow (#3023) became the first shipped end-to-end automation built on the engine. Late-month hardening added bounded-concurrency fan-out (#3224), quote-aware expression parsing (#3232, #3197), stricter gate and `wait_for` validation (#3233, #3225), and correct non-zero exit codes on failed or aborted runs (#2959). The engine that began as a fixed seven-step sequence is now a programmable, community-extensible automation substrate. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### Architecture & Refactoring
|
||||
|
||||
The **`__init__.py` decomposition series** advanced from 4/8 to **7/8** during June. PR 5/8 co-located integration commands in the `integrations/` domain directory (#2720), PR 6/8 extracted preset command handlers into `presets/_commands.py` (#2826), and PR 7/8 moved extension command handlers into `extensions/_commands.py` (#3014). The systematic extraction continues to improve contributor onboarding and test isolation, with one part remaining. Dead HTTP helpers (`open_github_url`, `_StripAuthOnRedirect`) were removed following the preset URL-install hardening (#2883). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### Bug Fixes and Security
|
||||
|
||||
Twenty-five releases produced a heavy cadence of fixes, concentrated on **cross-platform parity** and **workflow robustness**. Windows/PowerShell saw the most attention: the PowerShell 5.1 init hang (#2938), UTF-8 stdout/stderr (#2817), stderr routing for `check-prerequisites.ps1` (#3123), case-sensitive branch-name acronym parity (#3129), and several bash-parity script fixes (#3196, #3198, #3230, #3231). Workflow correctness improved with loud failures on unknown expression filters (#3074), rejection of phantom permissions gates (#3079), and preserved commas inside quoted list literals (#3134). Long-standing bugs closed include the 233-day worktree branch-numbering repeat (#1066) and the extension-command registration gap on integration upgrade (#2886).
|
||||
|
||||
Security and supply-chain work was a distinct theme this month. **Preset URL installs were hardened against unsafe redirects** (#2911), **`run_command` now rejects `shell=True`** (#3132), **command-registration path handling was hardened** (#3088), **CI actions were pinned to commit SHAs with shellcheck added** (#3126), **catalog archives are verified by sha256 before install** (#3080), the **extension self-install path can no longer delete its source directory** (#2991), **per-extension failures are isolated** so one bad extension can't drop the rest (#2951), and **host-less catalog URLs are now rejected** in the base and preset validators (#3209). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Extension & Preset Ecosystem
|
||||
|
||||
The community extension catalog grew from 105 to **124 entries** during June — nineteen net additions across four steady weeks. Community presets grew from 21 to **23**.
|
||||
|
||||
Notable new extensions by category:
|
||||
|
||||
- **Verification & drift**: Golden Demo executable-reference + behavioral-drift detection, Coding Standards Drift Control, Spec Trace spec-to-code traceability
|
||||
- **External trackers & round-trip**: Linear integration (`spec-kit-linear`), Jira Integration via sync engine, Tasks to GitHub Project
|
||||
- **Autonomy & loops**: Loop Engineering (safe maker/checker agent loops), Research Harness
|
||||
- **Token & context economy**: Token Economy (routing, measured savings, context audits)
|
||||
- **Visibility & artifacts**: Spec Kit TLDR review dashboard, Data Model Diagram (Mermaid ER diagrams), Spec Roadmap
|
||||
- **Intake & discovery**: Improve (audit a codebase into prioritized spec prompts), Intake (structured requirement intake), Spec Kit Discovery
|
||||
- **Multi-project**: Multi-Sites Spec Kit, RAG Azure Builder, SpecKit Companion
|
||||
|
||||
The catalog also showed strong maintenance activity: **Linear Integration** advanced through several releases (to v0.7.0), **DocGuard — CDD Enforcement** progressed to v0.28.0, the **Superpowers** bridges continued rapid iteration, and **Architecture Guard**, **Security Review**, **Product Forge**, **MemoryLint**, and **Multi-Model Review** all shipped updates. New presets included **Command Density** and **SicarioSpec Core**, and the governance-preset family (a11y, agent-parity, cross-platform, iSAQB-architecture, architecture, security) received a coordinated round of updates. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
|
||||
|
||||
### Documentation & Docs Site
|
||||
|
||||
June closed several long-standing documentation gaps. A **guide for handling complex features** landed (#3004), and **evolving specs in existing projects** was formally documented (#2902, closing the 243-day #916). **Spec-persistence models** were documented (#2856), a **monorepo guide** was added (#3084), and **GitHub Copilot CLI guidance** joined the README (#2891). Reference docs for the new **bundles** and **integration catalog** subcommands were added (#3206, #3174), agent disclosure was strengthened to cover commits and per-round comments (#3071), and preset submissions now require a usage README with Spec Kit CLI syntax (#3104). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
## Community & Content
|
||||
|
||||
### Microsoft's First-Party Endorsement
|
||||
|
||||
On **June 10**, the **Microsoft Developer Blog** published *"Spec-Driven Development: A Spec-First Approach to AI-Native Engineering"* by Apoorv Gupta (Principal Software Engineer, Microsoft) — the first first-party, non-maintainer post to present SDD and position **GitHub Spec Kit as the toolkit that operationalizes it**. The article covers the seven-step lifecycle and walks through three real greenfield and brownfield case studies, distilling the practice to a single line: **"spec quality = output quality."** Coming from Microsoft's own developer platform rather than the maintainers, it was the month's clearest signal that spec-driven development has moved from community experiment to institutionally endorsed practice. [\[developer.microsoft.com\]](https://developer.microsoft.com/blog/spec-driven-development-ai-native-engineering)
|
||||
|
||||
### Press and Industry Coverage
|
||||
|
||||
June was the **highest-volume coverage month on record — 75 substantive articles** across more than 25 languages.
|
||||
|
||||
**Xebia / XPRT Magazine #21** (Hidde de Smet & Emanuele Bartolesi, June 17) published a 32-minute full six-command walkthrough covering both greenfield and brownfield, honest about markdown-review overhead and where spec quality becomes the bottleneck. [\[xebia.com\]](https://xebia.com/blog/building-software-with-spec-kit/)
|
||||
|
||||
**Design News** (Jacob Beningo, June 26) published *"A Practical Guide to Spec-Driven Development with AI"*, explaining SDD for embedded engineers and highlighting Spec Kit as the agent-agnostic reference tool — notable for reaching an audience well outside the usual web-developer sphere. [\[designnews.com\]](https://www.designnews.com/embedded-systems/a-practical-guide-to-spec-driven-development-with-ai)
|
||||
|
||||
**SSOJet** (David Brown, June 26) surveyed seven SDD tools and named GitHub Spec Kit **"the category anchor and default agent-neutral pick."** [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
|
||||
|
||||
**The Tokenizer** (Sairam Sundaresan, June 12), a curated AI newsletter, spotlighted `github/spec-kit` as the structured alternative to one-shot prompting alongside coverage of Spotify and DeepMind. [\[artofsaience.com\]](https://newsletter.artofsaience.com/p/spotifys-agent-context-layer-deepminds)
|
||||
|
||||
**FintechExtra** (June 1) published a factual v0.9.x release-notes summary covering the agent-context migration to an opt-in extension, UTF-8 CLI encoding fixes, JSON workflow output, and headless CLI dispatch. [\[fintechextra.com\]](https://www.fintechextra.com/news/spec-kit-v090-agent-context-migration-to-extension-608)
|
||||
|
||||
### Enterprise Adoption
|
||||
|
||||
**SNCF Connect & Tech** — the technology arm of France's national railway — went on the record in a **CIO Online** interview (Reynald Fléchaux, June 30). CTO Emmanuel Cordente reported **2–4× velocity gains** from adopting spec-driven development via open-source frameworks it named explicitly, including Spec Kit, while candidly flagging token-cost and governance concerns. It is one of the first named-enterprise, on-the-record velocity claims for SDD. [\[cio-online.com\]](https://www.cio-online.com/actualites/lire-emmanuel-cordente-sncf-connect-et-tech--avec-le-spec-driven-development-une-vitesse-multipliee-par-2-a-4-17120.html)
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
June's 75 articles skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
|
||||
|
||||
Notable English-language articles:
|
||||
|
||||
- **Achraf Ben Alaya** (Azure MVP, June 28) published an honest .NET 10 / Blazor field report praising plan→tasks decomposition and the converge loop while flagging migration pitfalls and "overwhelming" markdown output. [\[achrafbenalaya.com\]](https://achrafbenalaya.com/2026/06/28/i-tried-github-spec-kit-an-honest-field-report/)
|
||||
- **Particula Tech** (Sebastian Mondragon, June 18) compared Spec Kit, Kiro, and Tessl, calling Spec Kit the heaviest and most flexible (30+ agents) but "prone to review overload" — match tool weight to task. [\[particula.tech\]](https://particula.tech/blog/spec-driven-development-tools-spec-kit-vs-kiro-vs-tessl)
|
||||
- **ToolTwist** (Portia Canlas, June 10) published a CxO field guide to BMAD, OpenSpec, and Spec Kit, concluding "none is best" and calling Spec Kit the **safe default for scaling teams**. [\[tooltwist.com\]](https://tooltwist.com/insights/spec-driven-frameworks-cxo-guide)
|
||||
- **Allegro Tech** (Konrad Piechna, June 8) shared hard-won SDD best practices, threading Spec Kit's Specify→Plan→Implement→Validate model throughout. [\[blog.allegro.tech\]](https://blog.allegro.tech/2026/06/spec-driven-development-best-practices.html)
|
||||
- **Yauhen Pyl** (June 3) published a hands-on scoring comparison rating Spec-Kit 2.77 vs OpenSpec 4.00 for brownfield/DX — praising the constitution model while calling it verbose and greenfield-biased. [\[ypyl.github.io\]](https://ypyl.github.io/programming/2026/06/03/openspec-vs-spec-kit-sdd.html)
|
||||
|
||||
Notable non-English coverage:
|
||||
|
||||
- **Japanese**: haru_iida published a thorough install + `/speckit.*` tutorial on Zenn from 6+ months of use. [\[zenn.dev\]](https://zenn.dev/haru_iida/articles/github-spec-kit-guide) A Qiita piece by IBM's Tomoyuki Hori documented integrating Spec Kit into the IBM Bob IDE. [\[qiita.com\]](https://qiita.com/Tomoyuki_Hori/items/eb0b1db560ba804cf8ac)
|
||||
- **Chinese**: 掘金 (juejin.cn) ran multiple three-way "Spec Kit vs OpenSpec vs Superpowers" decision guides, and 腾讯云 published a balanced "spec as scaffolding vs single truth" analysis. [\[juejin.cn\]](https://juejin.cn/post/7657070407262421007)
|
||||
- **Korean**: velog and Naver carried a wave of hands-on build logs and honest "is it too heavy?" critiques, including a full Claude Code + Spec-Kit end-to-end build. [\[velog.io\]](https://velog.io/@yono/GitHub-Spec-Kit%EC%9C%BC%EB%A1%9C-Spec-Driven-Development-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0)
|
||||
- **Russian**: a vc.ru field report trialed Spec Kit across four projects, concluding roughly 30% of the author's work suits it — strong on greenfield, weak on research and existing code. [\[vc.ru\]](https://vc.ru/ai/2974391-opyt-ispolzovaniya-spec-kit-na-proyektakh)
|
||||
|
||||
Coverage also appeared on TabNews (Portuguese), Habr and CSDN, note.com, Substack (multiple), Medium, DEV Community, Design News, and company engineering blogs — the broadest linguistic spread yet recorded.
|
||||
|
||||
### Community Growth by the Numbers
|
||||
|
||||
| Metric | Start of June | End of June | Change |
|
||||
| --- | --- | --- | --- |
|
||||
| GitHub stars | 106,951 | ~116,500 | +~9,500 (+9%) |
|
||||
| Forks | 9,464 | ~10,250 | +~800 |
|
||||
| Contributors | 217 | 245 | +28 |
|
||||
| Releases (total) | 152 | 177 | +25 (v0.9.0–v0.12.2) |
|
||||
| Community extensions | 105 | 124 | +19 |
|
||||
| Community presets | 21 | 23 | +2 |
|
||||
| Discussions (open) | 422 | 436 | +14 |
|
||||
|
||||
## SDD Ecosystem & Industry Trends
|
||||
|
||||
### The Category Consolidates
|
||||
|
||||
Across June's record article volume, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
|
||||
|
||||
### Competitive Landscape
|
||||
|
||||
The "which SDD tool?" comparison became June's dominant content genre, almost always featuring the same field: **Spec Kit, OpenSpec, Superpowers, BMAD, Kiro, Tessl, and GSD**. The recurring conclusion — from ToolTwist, BrainGrid, Particula Tech, and numerous multilingual surveys — was that the *practice* matters more than the tool, with Spec Kit positioned as the portable, community-driven, agent-agnostic default and competitors differentiating on brownfield ergonomics and drift management. Balanced reviews were consistent about the trade-off: Spec Kit is the heaviest and most flexible option (30+ agents, a full constitution/lifecycle model), which brings both the widest capability surface and the most review overhead. Hands-on scoring pieces (ypyl, vc.ru) rated it strong on greenfield and multi-scenario work and weaker on research tasks and incremental brownfield edits — precisely the gaps the `/speckit.converge` loop and the growing brownfield/drift extension ecosystem are built to close. [\[tooltwist.com\]](https://tooltwist.com/insights/spec-driven-frameworks-cxo-guide)
|
||||
|
||||
## Roadmap
|
||||
|
||||
Areas under discussion or in progress for future development:
|
||||
|
||||
- **The convergence loop** — `/speckit.converge` (#3001) is the core's direct answer to the drift-and-verification concern raised in nearly every review. Expect the append-only convergence model to deepen, and the community drift/verification extensions (Golden Demo, Spec Trace, Coding Standards Drift Control) to keep feeding requirements upstream. [\[github.com\]](https://github.com/github/spec-kit/blob/main/docs/quickstart.md)
|
||||
- **The bundle subsystem** — `specify bundle` (#3070) establishes role-based distribution as a first-class primitive. With a community submission path now open (#3162) and four example roles shipped, curation, trust signals (`verified` vs `community`), and version-pin enforcement become the next areas to mature. [\[github.com\]](https://github.com/github/spec-kit/blob/main/docs/reference/bundles.md)
|
||||
- **A programmable workflow platform** — with the step catalog, JSON output, and `from_json` filter, workflows are now community-extensible and scriptable. The open question is discoverability and pull: the step catalog is new, and adoption will show whether standalone workflow authoring becomes a real ecosystem or stays a power-user niche. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **PyPI publishing** — a publishing workflow and README metadata landed (#2915, closing #2623), but official PyPI distribution is not yet the recommended install path; `uv tool install` and git remain canonical. Completing and hardening this reduces friction for restricted/air-gapped environments. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **CLI architecture cleanup** — the `__init__.py` decomposition reached 7/8 (extensions/_commands.py, #3014), with one part remaining. The payoff is contributor onboarding and test isolation. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Toward a stable release** — v0.10.0's removal of `--no-git` and the git extension going opt-in was the first real breaking change, and the run to v0.12 reflects sustained pre-1.0 momentum. Expect continued API stabilization as the surface (bundles, workflows, converge) settles. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Experience simplification** — review overload, ceremony for small tasks, and verbose markdown output remain the most-cited concerns across June's balanced reviews (Particula Tech, ypyl, vc.ru, multiple Korean and Japanese pieces). The lean preset, TinySpec, `/speckit.converge`, and role bundles provide answers; surfacing them to new users is the ongoing opportunity. [\[particula.tech\]](https://particula.tech/blog/spec-driven-development-tools-spec-kit-vs-kiro-vs-tessl)
|
||||
@@ -99,7 +99,7 @@ The `CommandRegistrar` renders commands differently per agent:
|
||||
|
||||
| Agent | Format | Extension | Arg placeholder |
|
||||
|-------|--------|-----------|-----------------|
|
||||
| Claude, Kilo Code, opencode, Roo Code, etc. | Markdown | `.md` | `$ARGUMENTS` |
|
||||
| Claude, Kilo Code, opencode, etc. | Markdown | `.md` | `$ARGUMENTS` |
|
||||
| Copilot | Markdown | `.agent.md` + `.prompt.md` | `$ARGUMENTS` |
|
||||
| Gemini, Qwen, Tabnine | TOML | `.toml` | `{{args}}` |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.2.dev0"
|
||||
version = "0.12.3"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -304,3 +304,27 @@ def _display_project_path(project_root: Path, path: str | Path) -> str:
|
||||
except (OSError, ValueError):
|
||||
return path_obj.as_posix()
|
||||
return rel_path.as_posix()
|
||||
|
||||
|
||||
def version_satisfies(current: str, required: str) -> bool:
|
||||
"""Check if current version satisfies required version specifier.
|
||||
|
||||
Evaluates the version against the specifier using the project's
|
||||
prerelease policy (prereleases are allowed).
|
||||
|
||||
Args:
|
||||
current: Current version (e.g., "0.1.5")
|
||||
required: Required version specifier (e.g., ">=0.1.0,<2.0.0")
|
||||
|
||||
Returns:
|
||||
True if version satisfies requirement
|
||||
"""
|
||||
from packaging import version as pkg_version
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
|
||||
try:
|
||||
current_ver = pkg_version.Version(current)
|
||||
specifier = SpecifierSet(required)
|
||||
return specifier.contains(current_ver, prereleases=True)
|
||||
except (pkg_version.InvalidVersion, InvalidSpecifier):
|
||||
return False
|
||||
|
||||
@@ -180,9 +180,18 @@ def remove_source(project_root: Path, id_or_url: str) -> str:
|
||||
)
|
||||
|
||||
catalogs = _read(project_root)
|
||||
remaining = [
|
||||
c for c in catalogs if c.get("id") != target and c.get("url") != target
|
||||
]
|
||||
# Prefer an exact id/url match.
|
||||
remaining = [c for c in catalogs if c.get("id") != target and c.get("url") != target]
|
||||
if len(remaining) == len(catalogs):
|
||||
# No exact match. add_source canonicalizes a local path to an absolute
|
||||
# url before storing, so fall back to a canonicalized-url match -- this
|
||||
# lets `remove ./cat.json` undo `add ./cat.json` (stored absolute).
|
||||
# Only as a *fallback*: _canonicalize_url treats a bare id as a local
|
||||
# path (empty scheme), so applying it unconditionally could also delete a
|
||||
# different source whose url equals the id's canonicalized path.
|
||||
canonical = _canonicalize_url(target)
|
||||
if canonical != target:
|
||||
remaining = [c for c in catalogs if c.get("url") != canonical]
|
||||
if len(remaining) == len(catalogs):
|
||||
raise BundlerError(
|
||||
f"No project-scoped catalog source matching '{target}' was found."
|
||||
|
||||
@@ -28,7 +28,7 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
|
||||
from .._utils import dump_frontmatter, relative_extension_path_violation
|
||||
from .._utils import dump_frontmatter, relative_extension_path_violation, version_satisfies
|
||||
from ..catalogs import CatalogEntry as BaseCatalogEntry
|
||||
from ..catalogs import CatalogStackBase
|
||||
from ..shared_infra import verify_archive_sha256
|
||||
@@ -1279,20 +1279,20 @@ class ExtensionManager:
|
||||
CompatibilityError: If extension is incompatible
|
||||
"""
|
||||
required = manifest.requires_speckit_version
|
||||
current = pkg_version.Version(speckit_version)
|
||||
|
||||
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
|
||||
try:
|
||||
specifier = SpecifierSet(required)
|
||||
if current not in specifier:
|
||||
raise CompatibilityError(
|
||||
f"Extension requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
|
||||
)
|
||||
SpecifierSet(required) # Just to validate
|
||||
except InvalidSpecifier:
|
||||
raise CompatibilityError(f"Invalid version specifier: {required}")
|
||||
|
||||
if not version_satisfies(speckit_version, required):
|
||||
raise CompatibilityError(
|
||||
f"Extension requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def install_from_directory(
|
||||
@@ -1871,24 +1871,6 @@ class ExtensionManager:
|
||||
return None
|
||||
|
||||
|
||||
def version_satisfies(current: str, required: str) -> bool:
|
||||
"""Check if current version satisfies required version specifier.
|
||||
|
||||
Args:
|
||||
current: Current version (e.g., "0.1.5")
|
||||
required: Required version specifier (e.g., ">=0.1.0,<2.0.0")
|
||||
|
||||
Returns:
|
||||
True if version satisfies requirement
|
||||
"""
|
||||
try:
|
||||
current_ver = pkg_version.Version(current)
|
||||
specifier = SpecifierSet(required)
|
||||
return current_ver in specifier
|
||||
except (pkg_version.InvalidVersion, InvalidSpecifier):
|
||||
return False
|
||||
|
||||
|
||||
class CommandRegistrar:
|
||||
"""Handles registration of extension commands with AI agents.
|
||||
|
||||
|
||||
@@ -74,7 +74,6 @@ def _register_builtins() -> None:
|
||||
from .pi import PiIntegration
|
||||
from .qodercli import QodercliIntegration
|
||||
from .qwen import QwenIntegration
|
||||
from .roo import RooIntegration
|
||||
from .rovodev import RovodevIntegration
|
||||
from .shai import ShaiIntegration
|
||||
from .tabnine import TabnineIntegration
|
||||
@@ -111,7 +110,6 @@ def _register_builtins() -> None:
|
||||
_register(PiIntegration())
|
||||
_register(QodercliIntegration())
|
||||
_register(QwenIntegration())
|
||||
_register(RooIntegration())
|
||||
_register(RovodevIntegration())
|
||||
_register(ShaiIntegration())
|
||||
_register(TabnineIntegration())
|
||||
|
||||
@@ -96,7 +96,11 @@ class ClineIntegration(MarkdownIntegration):
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
indent = m.group(1)
|
||||
instruction = m.group(2)
|
||||
eol = m.group(3)
|
||||
# ``eol`` is empty when the regex matched via ``$`` because the
|
||||
# instruction was the final line of a file with no trailing
|
||||
# newline. Default to ``\n`` so the note never collapses onto
|
||||
# the same line as the instruction.
|
||||
eol = m.group(3) or "\n"
|
||||
return (
|
||||
indent
|
||||
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||
|
||||
@@ -57,6 +57,17 @@ def _allow_all() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _warn_legacy_markdown_default() -> None:
|
||||
"""Warn that Copilot's default markdown scaffold is being phased out."""
|
||||
warnings.warn(
|
||||
"Copilot legacy markdown mode is deprecated and will stop being the "
|
||||
'default in a future Spec Kit release; pass --integration-options "--skills" '
|
||||
"to opt in to Copilot skills mode now.",
|
||||
UserWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
|
||||
class _CopilotSkillsHelper(SkillsIntegration):
|
||||
"""Internal helper used when Copilot is scaffolded in skills mode.
|
||||
|
||||
@@ -316,6 +327,8 @@ class CopilotIntegration(IntegrationBase):
|
||||
self._skills_mode = bool(parsed_options.get("skills"))
|
||||
if self._skills_mode:
|
||||
return self._setup_skills(project_root, manifest, parsed_options, **opts)
|
||||
if "skills" not in parsed_options:
|
||||
_warn_legacy_markdown_default()
|
||||
return self._setup_default(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
def _setup_default(
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Roo Code integration."""
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
|
||||
|
||||
class RooIntegration(MarkdownIntegration):
|
||||
key = "roo"
|
||||
config = {
|
||||
"name": "Roo Code",
|
||||
"folder": ".roo/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".roo/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
@@ -30,7 +30,7 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from ..integrations.base import IntegrationBase
|
||||
from .._utils import dump_frontmatter
|
||||
from .._utils import dump_frontmatter, version_satisfies
|
||||
from ..shared_infra import verify_archive_sha256
|
||||
|
||||
|
||||
@@ -572,19 +572,16 @@ class PresetManager:
|
||||
PresetCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
required = manifest.requires_speckit_version
|
||||
current = pkg_version.Version(speckit_version)
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(required)
|
||||
if current not in specifier:
|
||||
raise PresetCompatibilityError(
|
||||
f"Preset requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
|
||||
)
|
||||
SpecifierSet(required) # Just to validate
|
||||
except InvalidSpecifier:
|
||||
raise PresetCompatibilityError(f"Invalid version specifier: {required}")
|
||||
|
||||
if not version_satisfies(speckit_version, required):
|
||||
raise PresetCompatibilityError(
|
||||
f"Invalid version specifier: {required}"
|
||||
f"Preset requires spec-kit {required}, "
|
||||
f"but {speckit_version} is installed.\n"
|
||||
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
1700
src/specify_cli/workflows/_commands.py
Normal file
1700
src/specify_cli/workflows/_commands.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,10 @@ class DoWhileStep(StepBase):
|
||||
)
|
||||
max_iter = config.get("max_iterations")
|
||||
if max_iter is not None:
|
||||
if not isinstance(max_iter, int) or max_iter < 1:
|
||||
# bool is a subclass of int, so isinstance(True, int) is True and
|
||||
# True < 1 is False; reject bools explicitly so `max_iterations: true`
|
||||
# is a type error rather than a silent single iteration.
|
||||
if isinstance(max_iter, bool) or not isinstance(max_iter, int) or max_iter < 1:
|
||||
errors.append(
|
||||
f"Do-while step {config.get('id', '?')!r}: "
|
||||
f"'max_iterations' must be an integer >= 1."
|
||||
|
||||
@@ -55,7 +55,10 @@ class WhileStep(StepBase):
|
||||
)
|
||||
max_iter = config.get("max_iterations")
|
||||
if max_iter is not None:
|
||||
if not isinstance(max_iter, int) or max_iter < 1:
|
||||
# bool is a subclass of int, so isinstance(True, int) is True and
|
||||
# True < 1 is False; reject bools explicitly so `max_iterations: true`
|
||||
# is a type error rather than a silent single iteration.
|
||||
if isinstance(max_iter, bool) or not isinstance(max_iter, int) or max_iter < 1:
|
||||
errors.append(
|
||||
f"While step {config.get('id', '?')!r}: "
|
||||
f"'max_iterations' must be an integer >= 1."
|
||||
|
||||
@@ -90,6 +90,22 @@ class TestClineIntegration(MarkdownIntegrationTests):
|
||||
assert "replace dots (`.`) with hyphens (`-`)" in injected
|
||||
assert "- For each executable hook, output the following:" in injected
|
||||
|
||||
def test_cline_hook_instruction_injection_no_trailing_newline(self):
|
||||
"""Note must not collapse onto the instruction line when the
|
||||
instruction is the final line with no trailing newline.
|
||||
|
||||
The injection regex matches the end-of-line via ``(\\r\\n|\\n|$)``, so
|
||||
the captured ``eol`` is empty on a file's last line that lacks a
|
||||
trailing newline. Without an ``or "\\n"`` fallback the note text and
|
||||
the instruction are emitted on the same line.
|
||||
"""
|
||||
cline = get_integration("cline")
|
||||
content = "- For each executable hook, output the following:" # no trailing \n
|
||||
injected = cline._inject_hook_command_note(content)
|
||||
assert "replace dots (`.`) with hyphens (`-`)" in injected
|
||||
# Instruction stays on its own line rather than being mashed onto the note.
|
||||
assert "\n- For each executable hook, output the following:" in injected
|
||||
|
||||
# -- Overrides for MarkdownIntegrationTests ---------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
@@ -34,6 +36,31 @@ class TestCopilotIntegration:
|
||||
assert f.parent == tmp_path / ".github" / "agents"
|
||||
assert f.name.endswith(".agent.md")
|
||||
|
||||
def test_setup_warns_legacy_markdown_default_is_deprecated(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
with pytest.warns(UserWarning, match="Copilot legacy markdown mode is deprecated"):
|
||||
created = copilot.setup(tmp_path, m)
|
||||
|
||||
assert any(f.name.endswith(".agent.md") for f in created)
|
||||
|
||||
def test_skills_setup_does_not_warn_about_legacy_default(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
|
||||
|
||||
assert not any(
|
||||
"Copilot legacy markdown mode is deprecated" in str(item.message)
|
||||
for item in caught
|
||||
)
|
||||
assert any(f.name == "SKILL.md" for f in created)
|
||||
|
||||
def test_setup_creates_companion_prompts(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
@@ -295,6 +322,51 @@ class TestCopilotIntegration:
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
|
||||
def test_default_cli_init_warns_legacy_markdown_is_deprecated(self, tmp_path):
|
||||
"""Default Copilot init should warn users about the future skills default."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "default-warning"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
with pytest.warns(
|
||||
UserWarning,
|
||||
match="Copilot legacy markdown mode is deprecated",
|
||||
):
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
def test_skills_cli_init_does_not_warn_about_legacy_markdown(self, tmp_path):
|
||||
"""Explicit Copilot skills mode should not warn about the legacy default."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "skills-no-warning"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not any(
|
||||
"Copilot legacy markdown mode is deprecated" in str(item.message)
|
||||
for item in caught
|
||||
)
|
||||
|
||||
|
||||
class TestCopilotSkillsMode:
|
||||
"""Tests for Copilot integration in --skills mode."""
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Tests for RooIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestRooIntegration(MarkdownIntegrationTests):
|
||||
KEY = "roo"
|
||||
FOLDER = ".roo/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".roo/commands"
|
||||
@@ -22,7 +22,7 @@ ALL_INTEGRATION_KEYS = [
|
||||
"copilot",
|
||||
# Stage 3 — standard markdown integrations
|
||||
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
|
||||
"roo", "rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
|
||||
"rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
|
||||
"pi", "kiro-cli", "vibe", "cursor-agent", "firebender",
|
||||
# Stage 4 — TOML integrations
|
||||
"gemini", "tabnine",
|
||||
@@ -244,3 +244,26 @@ class TestMultiInstallSafeContracts:
|
||||
f"{initial} and {additional} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(initial_files & additional_files)}"
|
||||
)
|
||||
|
||||
|
||||
class TestCatalogParity:
|
||||
"""The discovery catalog must list every registered integration."""
|
||||
|
||||
def test_every_registered_integration_is_in_catalog(self):
|
||||
"""``integrations/catalog.json`` must cover every registry key.
|
||||
|
||||
The catalog is the discovery manifest; an integration that is
|
||||
registered, registrar-aligned and registry-tested but missing from
|
||||
the catalog is undiscoverable through it. ``generic`` is exempt —
|
||||
it is the no-fixed-directory fallback, not a catalogued agent.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
catalog = json.loads(
|
||||
(repo_root / "integrations" / "catalog.json").read_text(encoding="utf-8")
|
||||
)
|
||||
catalogued = set(catalog["integrations"])
|
||||
registered = set(INTEGRATION_REGISTRY) - {"generic"}
|
||||
missing = sorted(registered - catalogued)
|
||||
assert not missing, f"integrations missing from catalog.json: {missing}"
|
||||
|
||||
@@ -38,7 +38,6 @@ ISSUE_TEMPLATE_AGENT_KEYS = [
|
||||
"pi",
|
||||
"qodercli",
|
||||
"qwen",
|
||||
"roo",
|
||||
"rovodev",
|
||||
"shai",
|
||||
"tabnine",
|
||||
|
||||
@@ -37,8 +37,8 @@ from specify_cli.extensions import (
|
||||
ValidationError,
|
||||
CompatibilityError,
|
||||
normalize_priority,
|
||||
version_satisfies,
|
||||
)
|
||||
from specify_cli._utils import version_satisfies
|
||||
|
||||
# Minimal valid ZIP (empty end-of-central-directory record). Passes
|
||||
# zipfile.is_zipfile() so --from download tests exercise the content guard.
|
||||
@@ -1005,6 +1005,14 @@ class TestExtensionManager:
|
||||
with pytest.raises(CompatibilityError, match="Extension requires spec-kit"):
|
||||
manager.check_compatibility(manifest, "0.0.1")
|
||||
|
||||
def test_check_compatibility_allows_prerelease_builds(self, extension_dir, project_dir):
|
||||
"""Prerelease spec-kit builds should satisfy compatible version ranges."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
|
||||
result = manager.check_compatibility(manifest, "0.8.8.dev0")
|
||||
assert result is True
|
||||
|
||||
def test_install_from_directory(self, extension_dir, project_dir):
|
||||
"""Test installing extension from directory."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
@@ -2629,6 +2637,12 @@ class TestVersionSatisfies:
|
||||
assert version_satisfies("1.0.5", ">=1.0.0,!=1.0.3")
|
||||
assert not version_satisfies("1.0.3", ">=1.0.0,!=1.0.3")
|
||||
|
||||
def test_version_satisfies_prerelease(self):
|
||||
"""Prerelease builds should satisfy compatible lower bounds, but not higher bounds."""
|
||||
assert version_satisfies("0.8.8.dev0", ">=0.2.0")
|
||||
assert not version_satisfies("0.2.0.dev0", ">=0.2.0")
|
||||
assert not version_satisfies("0.8.7.dev1", ">=0.8.8")
|
||||
|
||||
def test_version_satisfies_invalid(self):
|
||||
"""Test invalid version strings."""
|
||||
assert not version_satisfies("invalid", ">=1.0.0")
|
||||
|
||||
@@ -710,6 +710,15 @@ class TestPresetManager:
|
||||
manifest = PresetManifest(pack_dir / "preset.yml")
|
||||
assert manager.check_compatibility(manifest, "0.1.5") is True
|
||||
|
||||
def test_check_compatibility_prerelease(self, pack_dir, temp_dir):
|
||||
"""Test compatibility check allows prereleases and fails on boundary."""
|
||||
manager = PresetManager(temp_dir)
|
||||
manifest = PresetManifest(pack_dir / "preset.yml")
|
||||
# manifest requires >=0.1.0
|
||||
assert manager.check_compatibility(manifest, "0.8.8.dev0") is True
|
||||
with pytest.raises(PresetCompatibilityError, match="Preset requires spec-kit"):
|
||||
manager.check_compatibility(manifest, "0.1.0.dev0")
|
||||
|
||||
def test_check_compatibility_invalid(self, pack_dir, temp_dir):
|
||||
"""Test compatibility check with invalid specifier."""
|
||||
manager = PresetManager(temp_dir)
|
||||
|
||||
@@ -204,7 +204,91 @@ class TestWorkflowRunWithoutProject:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Refusing to use symlinked .specify path in current directory" in result.output
|
||||
assert "Refusing to use symlinked .specify path" in result.output
|
||||
|
||||
def test_workflow_run_yaml_rejects_symlinked_workflows_dir(self, tmp_path):
|
||||
"""Running local YAML should fail when .specify/workflows is a symlink."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
workflow_file = tmp_path / "test-workflow.yml"
|
||||
workflow_content = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "symlink-workflows-test",
|
||||
"name": "Symlink Workflows Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A workflow for symlink guard testing",
|
||||
},
|
||||
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
||||
}
|
||||
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
||||
|
||||
(tmp_path / ".specify").mkdir()
|
||||
target_dir = tmp_path / "real-workflows-dir"
|
||||
target_dir.mkdir()
|
||||
try:
|
||||
(tmp_path / ".specify" / "workflows").symlink_to(
|
||||
target_dir, target_is_directory=True
|
||||
)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", str(workflow_file),
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Refusing to use symlinked .specify/workflows path" in result.output
|
||||
|
||||
def test_workflow_run_yaml_rejects_symlinked_runs_dir(self, tmp_path):
|
||||
"""Running local YAML should fail when .specify/workflows/runs is a symlink."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
workflow_file = tmp_path / "test-workflow.yml"
|
||||
workflow_content = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "symlink-runs-test",
|
||||
"name": "Symlink Runs Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A workflow for symlink guard testing",
|
||||
},
|
||||
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
||||
}
|
||||
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
||||
|
||||
(tmp_path / ".specify" / "workflows").mkdir(parents=True)
|
||||
target_dir = tmp_path / "real-runs-dir"
|
||||
target_dir.mkdir()
|
||||
try:
|
||||
(tmp_path / ".specify" / "workflows" / "runs").symlink_to(
|
||||
target_dir, target_is_directory=True
|
||||
)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", str(workflow_file),
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Refusing to use symlinked .specify/workflows/runs path" in result.output
|
||||
|
||||
def test_workflow_run_yaml_rejects_non_directory_specify_path(self, tmp_path):
|
||||
"""Running local YAML should fail when .specify is not a directory."""
|
||||
|
||||
@@ -1822,6 +1822,12 @@ class TestWhileStep:
|
||||
step = WhileStep()
|
||||
errors = step.validate({"id": "test", "condition": "{{ true }}", "max_iterations": 0, "steps": []})
|
||||
assert any("must be an integer >= 1" in e for e in errors)
|
||||
# bool is an int subclass; `max_iterations: true` must be rejected, not
|
||||
# silently treated as a single iteration.
|
||||
bool_errors = step.validate(
|
||||
{"id": "test", "condition": "{{ true }}", "max_iterations": True, "steps": []}
|
||||
)
|
||||
assert any("must be an integer >= 1" in e for e in bool_errors)
|
||||
|
||||
|
||||
class TestDoWhileStep:
|
||||
@@ -1861,6 +1867,21 @@ class TestDoWhileStep:
|
||||
assert len(result.next_steps) == 1
|
||||
assert result.output["max_iterations"] == 5
|
||||
|
||||
def test_validate_rejects_bool_max_iterations(self):
|
||||
from specify_cli.workflows.steps.do_while import DoWhileStep
|
||||
|
||||
step = DoWhileStep()
|
||||
# bool is an int subclass; `max_iterations: true` must be rejected.
|
||||
errors = step.validate(
|
||||
{"id": "test", "condition": "{{ true }}", "max_iterations": True, "steps": []}
|
||||
)
|
||||
assert any("must be an integer >= 1" in e for e in errors)
|
||||
# a real positive integer is fully valid (no errors at all).
|
||||
ok = step.validate(
|
||||
{"id": "test", "condition": "{{ true }}", "max_iterations": 3, "steps": []}
|
||||
)
|
||||
assert ok == [], ok
|
||||
|
||||
def test_execute_empty_steps(self):
|
||||
from specify_cli.workflows.steps.do_while import DoWhileStep
|
||||
from specify_cli.workflows.base import StepContext
|
||||
@@ -5282,6 +5303,279 @@ class TestWorkflowStepRemoveCLI:
|
||||
assert "Refusing to use symlinked step directory" in result.output
|
||||
|
||||
|
||||
class TestWorkflowRemoveGuard:
|
||||
def test_remove_rejects_traversal_registry_key(self, project_dir, monkeypatch):
|
||||
"""A corrupted registry key must not let remove delete outside workflows/."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowRegistry
|
||||
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
registry.add("../outside", {"name": "Bad"})
|
||||
outside = project_dir / ".specify" / "outside"
|
||||
outside.mkdir()
|
||||
sentinel = outside / "keep.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "remove", "../outside"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid workflow ID" in result.output
|
||||
assert sentinel.read_text(encoding="utf-8") == "keep"
|
||||
|
||||
@pytest.mark.parametrize("workflow_id", ["runs", "steps"])
|
||||
def test_remove_rejects_reserved_storage_ids(
|
||||
self, project_dir, monkeypatch, workflow_id
|
||||
):
|
||||
"""Reserved workflow storage directories must never be removable workflows."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowRegistry
|
||||
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
registry.add(workflow_id, {"name": "Bad"})
|
||||
reserved_dir = project_dir / ".specify" / "workflows" / workflow_id
|
||||
reserved_dir.mkdir(exist_ok=True)
|
||||
sentinel = reserved_dir / "keep.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "remove", workflow_id])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid workflow ID" in result.output
|
||||
assert sentinel.read_text(encoding="utf-8") == "keep"
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_remove_refuses_symlinked_workflow_dir(self, project_dir, monkeypatch):
|
||||
"""A symlinked workflow directory must not let remove delete its target."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowRegistry
|
||||
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
registry.add("test-wf", {"name": "Test"})
|
||||
outside = project_dir / "outside-workflow-remove-target"
|
||||
outside.mkdir(exist_ok=True)
|
||||
sentinel = outside / "keep.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
(project_dir / ".specify" / "workflows" / "test-wf").symlink_to(
|
||||
outside, target_is_directory=True
|
||||
)
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "symlinked .specify/workflows/test-wf" in result.output
|
||||
assert sentinel.read_text(encoding="utf-8") == "keep"
|
||||
assert WorkflowRegistry(project_dir).is_installed("test-wf")
|
||||
|
||||
def test_remove_refuses_non_directory_workflow_path(self, project_dir, monkeypatch):
|
||||
"""A file at the workflow path must fail cleanly instead of crashing."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowRegistry
|
||||
|
||||
registry = WorkflowRegistry(project_dir)
|
||||
registry.add("test-wf", {"name": "Test"})
|
||||
workflow_path = project_dir / ".specify" / "workflows" / "test-wf"
|
||||
workflow_path.write_text("not a directory", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "remove", "test-wf"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "exists but is not a directory" in result.output
|
||||
assert workflow_path.read_text(encoding="utf-8") == "not a directory"
|
||||
assert WorkflowRegistry(project_dir).is_installed("test-wf")
|
||||
|
||||
|
||||
class TestWorkflowAddSymlinkGuard:
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
|
||||
"""workflow add must refuse a symlinked .specify (writes could escape root)."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
outside = temp_dir.parent / "outside-specify-target"
|
||||
(outside / "workflows").mkdir(parents=True, exist_ok=True)
|
||||
(temp_dir / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", "anything.yml"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "symlinked .specify" in result.output
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_workflows_dir(self, temp_dir, monkeypatch):
|
||||
"""workflow add must refuse a symlinked .specify/workflows directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify").mkdir()
|
||||
outside = temp_dir.parent / "outside-workflows-target"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
(temp_dir / ".specify" / "workflows").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", "anything.yml"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "symlinked .specify/workflows" in result.output
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_id_dir(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
"""A symlinked <id> install dir must not let a copy escape the project root."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
|
||||
outside = temp_dir.parent / "outside-id-target"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
# <id> from the YAML below is "test-workflow"; plant it as a symlink.
|
||||
(temp_dir / ".specify" / "workflows" / "test-workflow").symlink_to(
|
||||
outside, target_is_directory=True
|
||||
)
|
||||
src = temp_dir / "incoming.yml"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
# No write-through: the symlink target stays empty.
|
||||
assert not (outside / "workflow.yml").exists()
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_workflow_yml_leaf(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
"""A symlinked <id>/workflow.yml must not let copy2 write through the link."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
id_dir = temp_dir / ".specify" / "workflows" / "test-workflow"
|
||||
id_dir.mkdir(parents=True)
|
||||
outside_file = temp_dir.parent / "outside-leaf-target.yml"
|
||||
outside_file.write_text("original\n", encoding="utf-8")
|
||||
(id_dir / "workflow.yml").symlink_to(outside_file)
|
||||
src = temp_dir / "incoming.yml"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
# Rich may wrap the message; assert on the unbroken path fragment.
|
||||
assert "test-workflow/workflow.yml" in result.output
|
||||
assert "symlinked" in result.output
|
||||
# The link target content is untouched.
|
||||
assert outside_file.read_text(encoding="utf-8") == "original\n"
|
||||
|
||||
def test_add_refuses_non_directory_id(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
"""An <id> path that already exists as a file must fail cleanly, not crash."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
wf_dir = temp_dir / ".specify" / "workflows"
|
||||
wf_dir.mkdir(parents=True)
|
||||
(wf_dir / "test-workflow").write_text("not a dir", encoding="utf-8")
|
||||
src = temp_dir / "incoming.yml"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "exists but is not a directory" in result.output
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
|
||||
def test_add_refuses_workflow_yml_as_directory(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
"""A pre-existing <id>/workflow.yml *directory* must fail cleanly, not crash."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
id_dir = temp_dir / ".specify" / "workflows" / "test-workflow"
|
||||
id_dir.mkdir(parents=True)
|
||||
# Plant workflow.yml as a directory so a later write/copy2 would raise
|
||||
# IsADirectoryError without the explicit non-file guard.
|
||||
(id_dir / "workflow.yml").mkdir()
|
||||
src = temp_dir / "incoming.yml"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "test-workflow/workflow.yml" in result.output
|
||||
assert "is not a file" in result.output
|
||||
# Clean exit, not an unhandled IsADirectoryError traceback.
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
|
||||
def test_safe_workflow_id_dir_escapes_markup_in_invalid_id(self, temp_dir, capsys):
|
||||
"""A traversal <id> carrying Rich markup must be escaped, not interpreted."""
|
||||
import typer
|
||||
from specify_cli.workflows._commands import _safe_workflow_id_dir
|
||||
|
||||
workflows_dir = temp_dir / ".specify" / "workflows"
|
||||
workflows_dir.mkdir(parents=True)
|
||||
# Traversal (so the "Invalid workflow ID" branch fires) plus markup.
|
||||
with pytest.raises(typer.Exit):
|
||||
_safe_workflow_id_dir(workflows_dir, "../[red]evil[/red]")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
# Literal bracketed text survives; Rich did not consume it as a tag.
|
||||
assert "[red]evil[/red]" in out
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workflow_id",
|
||||
[
|
||||
"runs",
|
||||
"steps",
|
||||
"nested/workflow",
|
||||
"nested\\workflow",
|
||||
"bad id",
|
||||
" bad-id",
|
||||
"bad-id ",
|
||||
],
|
||||
)
|
||||
def test_safe_workflow_id_dir_rejects_reserved_or_non_segment_ids(
|
||||
self, temp_dir, workflow_id, capsys
|
||||
):
|
||||
"""Install IDs must not collide with workflow internals or create nested paths."""
|
||||
import typer
|
||||
from specify_cli.workflows._commands import _safe_workflow_id_dir
|
||||
|
||||
workflows_dir = temp_dir / ".specify" / "workflows"
|
||||
workflows_dir.mkdir(parents=True)
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_safe_workflow_id_dir(workflows_dir, workflow_id)
|
||||
|
||||
assert "Invalid workflow ID" in capsys.readouterr().out
|
||||
assert not (workflows_dir / workflow_id).exists()
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_list_refuses_symlinked_runs_dir(self, temp_dir, monkeypatch):
|
||||
"""workflow commands using the project shim must refuse symlinked run storage."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
|
||||
outside = temp_dir.parent / "outside-runs-target"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
(temp_dir / ".specify" / "workflows" / "runs").symlink_to(
|
||||
outside, target_is_directory=True
|
||||
)
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "list"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "symlinked .specify/workflows/runs" in result.output
|
||||
|
||||
|
||||
class TestWorkflowStepAddCLI:
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_rejects_symlinked_steps_base_dir(self, project_dir, monkeypatch):
|
||||
@@ -5595,7 +5889,7 @@ steps:
|
||||
# at the file-descriptor level, so it sees the subprocess output too.
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
from specify_cli import _stdout_to_stderr_when
|
||||
from specify_cli.workflows._commands import _stdout_to_stderr_when
|
||||
|
||||
print("STDOUT_BEFORE")
|
||||
with _stdout_to_stderr_when(True):
|
||||
@@ -5614,7 +5908,7 @@ steps:
|
||||
assert "PY_LEAK" in err and "SUBPROC_LEAK" in err
|
||||
|
||||
def test_json_redirect_inactive_is_noop(self, capfd):
|
||||
from specify_cli import _stdout_to_stderr_when
|
||||
from specify_cli.workflows._commands import _stdout_to_stderr_when
|
||||
|
||||
with _stdout_to_stderr_when(False):
|
||||
print("VISIBLE_ON_STDOUT")
|
||||
@@ -6235,7 +6529,7 @@ steps:
|
||||
# not cleared afterwards, so a `completed`/`failed` run whose last
|
||||
# executed step was a gate must NOT surface a stale gate block.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
gate_step = {
|
||||
"type": "gate",
|
||||
@@ -6262,7 +6556,7 @@ steps:
|
||||
# message may be a non-string YAML literal (e.g. a number); the JSON
|
||||
# surface normalises it so the emitted schema stays stable.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
@@ -6281,7 +6575,7 @@ steps:
|
||||
# workflow; the JSON surface always normalises them to list[str] | None
|
||||
# so the emitted schema is stable regardless of the input shape.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
def _options_payload(options):
|
||||
state = SimpleNamespace(
|
||||
@@ -6311,7 +6605,7 @@ steps:
|
||||
# surface normalises it to str (and keeps None = no decision yet),
|
||||
# consistent with the message/options normalization.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
def _choice_payload(choice):
|
||||
state = SimpleNamespace(
|
||||
@@ -6335,7 +6629,7 @@ steps:
|
||||
# gate is still detected by its unique output signature (`on_reject`),
|
||||
# so resume surfaces the gate block instead of silently dropping it.
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
@@ -6361,7 +6655,7 @@ steps:
|
||||
# A typeless record lacking the gate signature must NOT be mistaken for
|
||||
# a gate (the fallback keys off `on_reject`, which only GateStep writes).
|
||||
from types import SimpleNamespace
|
||||
from specify_cli import _gate_outcome
|
||||
from specify_cli.workflows._commands import _gate_outcome
|
||||
|
||||
state = SimpleNamespace(
|
||||
status=SimpleNamespace(value="paused"),
|
||||
|
||||
@@ -69,6 +69,49 @@ def test_add_source_persists_absolute_local_path(tmp_path: Path, monkeypatch):
|
||||
assert Path(source.url) == catalog.resolve()
|
||||
|
||||
|
||||
def test_remove_source_accepts_relative_local_path(tmp_path: Path, monkeypatch):
|
||||
"""add_source stores a local path as an absolute url, so remove_source must
|
||||
accept the same relative path the caller added; otherwise `remove ./cat.json`
|
||||
cannot undo `add ./cat.json`."""
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
catalog = project / "sub" / "cat.json"
|
||||
catalog.parent.mkdir()
|
||||
catalog.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
cc.add_source(project, "sub/cat.json", policy="install-allowed", priority=50)
|
||||
# Removing with the same relative path must succeed (stored absolute).
|
||||
removed = cc.remove_source(project, "sub/cat.json")
|
||||
assert removed == "sub/cat.json"
|
||||
# And it is actually gone now.
|
||||
with pytest.raises(BundlerError, match="No project-scoped catalog source"):
|
||||
cc.remove_source(project, "sub/cat.json")
|
||||
|
||||
|
||||
def test_remove_by_id_does_not_also_delete_canonical_url_match(tmp_path: Path, monkeypatch):
|
||||
"""`remove <id>` must remove only the exact-id source, not also a different
|
||||
source whose url happens to equal the id's canonicalized path. (_canonicalize_url
|
||||
treats a bare id as a local path, so the canonical match is only a fallback when
|
||||
there is no exact id/url match.)"""
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
monkeypatch.chdir(project)
|
||||
# Source A: id "local", a remote url.
|
||||
cc.add_source(
|
||||
project, "https://example.com/a.json", source_id="local",
|
||||
policy="install-allowed", priority=10,
|
||||
)
|
||||
# Source B: a local path that canonicalizes to <cwd>/local, with a distinct id.
|
||||
cc.add_source(project, "local", source_id="bsource", policy="install-allowed", priority=20)
|
||||
|
||||
removed = cc.remove_source(project, "local")
|
||||
assert removed == "local"
|
||||
ids = {c["id"] for c in cc._read(project)}
|
||||
assert "local" not in ids # the exact-id source was removed
|
||||
assert "bsource" in ids # the canonical-url source survives (not collateral)
|
||||
|
||||
|
||||
def test_add_source_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
|
||||
Reference in New Issue
Block a user