From feb839103d7a46d4622e6b33ba11ceb56fdb1ab6 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 8 Apr 2026 20:27:39 +0500 Subject: [PATCH 001/184] Add Spec Refine community extension to catalog and README (#2118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Spec Refine community extension to catalog and README Adds the spec-kit-refine extension (4 commands, 2 hooks) that enables iterative specification refinement — update specs in-place, propagate changes to plan and tasks, diff impact, and track sync status. Addresses community request in issue #1191 (101+ upvotes). Co-Authored-By: Claude Sonnet 4.6 * Fix alphabetical ordering of S-entries in Community Extensions table Reorders Ship Release, Spec Critique, Spec Refine, Spec Sync, Staff Review, and Superpowers Bridge into correct alphabetical order per publishing guide. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- README.md | 5 +++-- extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 711abb341e..e996583429 100644 --- a/README.md +++ b/README.md @@ -221,11 +221,12 @@ The following community-contributed extensions are available in [`catalog.commun | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | | Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | -| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | -| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | +| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | +| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | +| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index da4fc75da3..6935624ee7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-06T06:30:00Z", + "updated_at": "2026-04-08T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1067,6 +1067,38 @@ "created_at": "2026-03-14T00:00:00Z", "updated_at": "2026-03-14T00:00:00Z" }, + "refine": { + "name": "Spec Refine", + "id": "refine", + "description": "Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-refine/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-refine", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-refine", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-refine/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-refine/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 2 + }, + "tags": [ + "refine", + "iterate", + "propagation", + "workflow", + "specifications" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z" + }, "repoindex": { "name": "Repository Index", "id": "repoindex", From 4d58ee945c7c5b12ea6df7b3508c19cab754f2c6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:35:06 -0500 Subject: [PATCH 002/184] Added March 2026 newsletter (#2124) * Added March 2026 newsletter * Use ASCII hyphen in newsletter title for consistency --- newsletters/2026-March.md | 86 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 newsletters/2026-March.md diff --git a/newsletters/2026-March.md b/newsletters/2026-March.md new file mode 100644 index 0000000000..1d42443dcd --- /dev/null +++ b/newsletters/2026-March.md @@ -0,0 +1,86 @@ +# Spec Kit - March 2026 Newsletter + +This edition covers Spec Kit activity in March 2026. Versions v0.2.0 through v0.4.3 shipped during the month — nine releases — introducing major capabilities including simultaneous multi-catalog extension support, a pluggable preset system, air-gapped offline deployment, and automatic skill registration for extensions. Seven new AI coding assistants were integrated, bringing total platform support past 22. Community activity included over twenty new extensions, independent walkthroughs and blog posts, and a wave of industry coverage debating whether "vibe coding" is dead. A category summary is in the table below, followed by details. + +| **Spec Kit Core (Mar 2026)** | **Community & Content** | **SDD Ecosystem & Next** | +| --- | --- | --- | +| Versions **v0.2.0** through **v0.4.3** shipped with major features: multi-catalog extensions, pluggable presets, air-gapped deployment, and auto-registration of extension skills. Seven new agents added (Tabnine CLI, Kimi Code, Mistral Vibe, Junie, iFlow, Trae, Pi). The repo grew from ~71k to **72,700 stars** by March 20. [\[github.com\]](https://github.com/github/spec-kit/releases) | Walkthroughs by Tiago Valverde, Alfredo Perez, and Sergey Golubev covered SDD in practice. Over 20 community extensions reached the catalog. The Spec Kit Assistant VS Code extension was recognized as a Community Friend. A Microsoft Learn training module on SDD with Spec Kit was available. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) | ByteIota reported AWS pushing SDD as the new standard; multiple independent articles declared "vibe coding" dead. Augment Code published a detailed Spec Kit vs. Intent comparison. Competitors differentiate on orchestration depth and living specs; Spec Kit leads in agent breadth and portability. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) | + +*** + +## Spec Kit Project Updates + +### Three Major Versions and Six Patches + +**v0.2.0** (released March 10) was the month's opening milestone. Its headline feature was **simultaneous multi-catalog support** (PR #1720), enabling users to activate both the core and community extension catalogs at the same time — a prerequisite for the modular ecosystem that would flourish throughout the rest of the month. The release bundled the first new agent integrations of March — **Tabnine CLI** (#1503) and **Kimi Code CLI** (#1790) — along with four community extensions: **Understanding** (#1778), **Ralph** (#1780), **Review** (#1775), and **Fleet Orchestrator** (#1771). Tooling improvements included `.extensionignore` support for excluding files during extension installation (#1781) and **Codex extension command registration** (#1767). Patch **v0.2.1** followed to fix broken quickstart links (#1759/#1797), add catalog CLI help documentation (#1793/#1794), and use quiet checkout to suppress git exceptions (#1792). The February 2026 newsletter was also committed as part of v0.2.1 (#1812). [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md) [\[github.com\]](https://github.com/github/spec-kit/releases) + +**v0.3.0** (mid-March) delivered one of the most anticipated features: a **pluggable preset system** with catalog, resolver, and skills propagation (#1787). Presets let teams override Spec Kit's default templates and commands with their own conventions — a mechanism that Thulasi Rajasekaran described on LinkedIn as "the layer that makes AI-assisted development governable". The system supports priority-based stacking: an organization can layer an enterprise-standards preset beneath a team-style preset, with lower priority numbers winning conflicts. Version 0.3.0 also added a **/selftest.extension** core extension for testing other extensions against the framework (#1758), **RFC-aligned catalog integration** quality-of-life improvements (#1776), and hardened bash scripts against shell injection (#1809). On the agent side, v0.3.0 added **Mistral Vibe CLI** (#1725), migrated **Qwen Code CLI** from TOML to Markdown format (#1589/#1730), and deprecated explicit command support for the Antigravity (agy) agent (#1798/#1808). Several new community extensions arrived, including **DocGuard CDD** (#1838), **Archive & Reconcile** (#1844), **specify-status** (#1837), and **specify-doctor** (#1828). [\[github.com\]](https://github.com/github/spec-kit/releases) [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) + +Patches came rapidly. **v0.3.1** wired **before/after hook events** into the specify and plan templates (#1886), added **JSONC deep-merge** support for `settings.json` (#1874), added the **Trae IDE** agent (#1817), and introduced priority-based resolution for both extensions and presets (#1855). A greenfield **Spring Boot pirate-speak preset** demo was published in the README (#1878), and a **Go/React brownfield walkthrough** using GitHub Copilot CLI was added to community walkthroughs (#1868). **v0.3.2** added four more new agent integrations — **JetBrains Junie** (#1831), **iFlow CLI** (#1875), and **Pi Coding Agent** (#1853) — plus migrated Codex/agy init to a native skills workflow (#1906). It also shipped a **preset submission template** (#1910) and an **Extension Comparison Guide** (#1897) to help the growing community navigate overlapping extensions. Additional community extensions added in this cycle included **verify-tasks** (#1871), **conduct** (#1908), **cognitive-squad** (#1870, updated to Triadic Model), **speckit-utils** (#1896), **spec-kit-iterate** (#1887), and **spec-kit-learn** (#1883). DocGuard received three version updates in quick succession (v0.9.8, v0.9.10, v0.9.11). [\[github.com\]](https://github.com/github/spec-kit/releases) + +**v0.4.0** (late March) introduced the month's headline usability feature: **auto-registration of extension skills** (#1840), so that any installed extension's commands are automatically exposed as agent skills without extra configuration. It also delivered **air-gapped/offline deployment** by embedding the core template pack directly in the CLI wheel (#1803), enabling Spec Kit to function in restricted environments with no internet access. A **timestamp-based branch naming** option was added for `specify init` (#1911) to better support parallel feature development. The YAML I/O layer was fixed to use `allow_unicode=True` and `encoding="utf-8"` (#1936), and the stale-issue GitHub Action was increased to 250 operations per run (#1922). New community extensions in this cycle included **Checkpoint** (#1947). [\[github.com\]](https://github.com/github/spec-kit/releases) + +Three rapid patches closed the month. **v0.4.1** fixed a missing **Assumptions section** in the spec template (#1939) and prioritized the `.specify` directory over the parent git root for **repo root detection** (#1933). **v0.4.2** was the month's most documentation-heavy release: it added **AIDE, Extensify, and Presetify** to the community catalog (#1961), moved the **community extensions table into the main README** for discoverability (#1959), added a **community presets** section (#1960), consolidated **Community Friends** sections (#1958), and formally recognized the **Spec Kit Assistant VS Code extension** (#1944). It also shipped a manual testing guide for slash command validation (#1955) and renamed "NFR" references to "success criteria" in the analyze and clarify commands (#1935). **v0.4.3** wrapped up the month by unifying Kimi/Codex skill naming and migrating legacy dotted directory names (#1971), and replacing the null-conditional operator in PowerShell scripts to restore **PowerShell 5.1 compatibility** (#1975). [\[github.com\]](https://github.com/github/spec-kit/releases) + +### Bug Fixes and Security Hardening + +Security and stability received substantial attention. The most significant fix was **shell injection hardening** of bash scripts (#1809), which addressed a class of potential injection vulnerabilities where unsanitized values from git branch names or environment variables could be passed through to shell commands. The v0.3.1 release followed up with additional bash escape and compatibility improvements (#1869). **Branch numbering** was overhauled: the old per-short-name detection scheme caused conflicts, so Spec Kit switched to **global branch numbering** (#1757) for consistent sequencing across feature branches. A **quiet git checkout** fix suppressed exceptions during branching (#1792), and a **git fetch stdout leak** was suppressed in multi-remote environments (#1876). **JSON control characters** were encoded as `\uXXXX` instead of being silently stripped (#1872). Explicit **PowerShell positional binding** was added to `create-new-feature` parameters (#1885), and the Codex native skills fallback was refreshed with legacy prompt suppression (#1930). [\[github.com\]](https://github.com/github/spec-kit/releases) + +### The Extension Catalog at Scale + +By late March, over **20 community extensions** had been built for Spec Kit. Thulasi Rajasekaran's LinkedIn article on March 20, titled *"The Feature That Turns Spec Kit Into a Platform: Extensions & Presets,"* provided a thorough analysis of what makes this ecosystem significant. Rajasekaran highlighted several standout extensions: **Conduct**, which orchestrates SDD phases by delegating to sub-agents to solve "context pollution" (where a single agent accumulates so many tokens that quality degrades); **Verify Tasks**, which scans task lists for "phantom completions" — tasks marked done with no real code behind them; **Understanding**, which runs 31 deterministic quality metrics against specifications based on IEEE/ISO standards ("like a linter for English"); and the **Jira and Azure DevOps integrations**, which auto-create work items from specs and tasks. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) + +Rajasekaran argued that the real significance of presets is not the mechanism itself — which is "almost comically simple" (a stack of Markdown files with a priority order) — but what it enables: the same machinery that turned "User Stories" into "Crew Tales" in the pirate-speak demo could turn them into compliance requirements with traceability IDs, add mandatory threat-model sections to every plan, or enforce TDD by requiring test tasks before implementation tasks. The pirate-speak preset, which Rajasekaran described in detail, was built by the Spec Kit maintainer using Spring Boot 4 and produced a fully functional application where every artifact — headings, paragraphs, status updates — was rendered in pirate prose. Organizations can curate which extensions are available to developers by hosting custom catalog URLs. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) + +## Community & Content + +### Developer Walkthroughs and Blog Posts + +March produced a wave of independent content as developers explored SDD in practice. + +**Tiago Valverde** published *"Spec-Driven Development in Practice: A Walkthrough with Spec Kit"* on March 14 (12-minute read). The post documents building an Instagram-style photo mural feature on a personal blog using the full Spec Kit workflow — from constitution setup through specification, clarification, planning, task breakdown, and implementation. Valverde contrasts the structured approach with previous ad-hoc prompting: while directly prompting Claude worked for small changes, for anything complex Valverde *"kept running into the same problems: scope creep mid-session, ambiguous requirements only noticed after implementation, and no artifact left behind to explain why a decision was made"*. The walkthrough includes actual Claude command syntax, expected file structures, and practical advice. Valverde recommends being *"overly specific in the initial prompt"* and to *"immediately review `spec.md`"* and add missing requirements before proceeding to planning. The clarify step is highlighted as particularly valuable — it surfaces guided questions with recommended options and identifies *"decisions that materially change complexity"*. On the broader philosophy, Valverde frames SDD as essential for production work: *"engineers still need to guarantee code quality, meet security requirements, keep documentation up to date, and keep all the stakeholders in sync with the project status and health"*. Valverde also published a shorter companion piece on March 8 titled *"The Shift from Vibe Coding to Spec-Driven Development,"* describing the broader industry trend. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) + +**Alfredo Perez** published *"Build Your Own SDD Workflow"* on March 21, taking a deliberately contrarian approach to Spec Kit's thoroughness. Perez praises spec-driven development in principle (*"planning before you code catches bad assumptions early, keeps scope honest, and gives the AI enough context to write code you'd actually ship"*) but argues the standard seven-step workflow carries too much ceremony for smaller tasks: *"When you're adding a component or fixing a bug, that's a lot of overhead for a Tuesday afternoon"*. Perez's solution is a lean **4-step custom workflow** — `specify → plan → tasks → implement` — dropping constitution, clarify, and review. The rationale: coding rules belong in `CLAUDE.md` once (not re-checked per feature), clarification can happen iteratively rather than as a formal upfront step, and review can be added back when warranted. The custom workflow is wired into the **SpecKit Companion** VS Code extension via a `speckit.customWorkflows` configuration in `.vscode/settings.json`, giving users a visual sidebar where each phase appears as a clickable step. Perez walks through a real feature implementation — adding a home page and navigation bar — showing each phase in action with screenshots of the SpecKit Companion panel. The article highlights an important tradeoff: **full rigor vs. lightweight adoption**. Not every project needs all seven steps, and Spec Kit's extensible design accommodates both extremes. Perez also presented this workflow live at an **Angular Community Meetup** on March 25 titled *"Create Your Own AI Workflow"*. [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) + +**Sergey Golubev** of prodfeat.ai published *"20+ SDD Frameworks: A Catalog for AI Development"* on March 17, the result of two weeks of research across YouTube, Telegram, Medium, Augment Code, ThoughtWorks Radar, and GitHub repositories. The catalog organizes **20+ frameworks in 6 categories** and highlights three standouts. **BMAD-METHOD** (\~41,000 stars) simulates an entire agile team from AI roles — Analyst, PM, Architect, Scrum Master — and produces a full PRD with architecture and dev stories; Golubev calls it *"perfect for a team of 3-5 people"* but notes a high barrier to entry. **QuintCode + FPF** is notable for its ADI Cycle in 5 phases (hypothesize, verify logic, test, audit, decide), which preserves decision rationale — *"three months later you won't remember the reasoning."* In one case study, FPF + ChatGPT Pro produced a 52-page spec and 280 feature files in two evenings, and QuintCode chose Docker Swarm over Kubernetes through reasoned analysis. **cc-sdd** (\~2,880 stars) provides Kiro-style SDD commands for 8 tools (Claude Code, Cursor, Gemini CLI, Codex CLI, and 4 more), with an enforced workflow that *"won't let you skip planning"*. [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) + +Golubev also presents a **three-level SDD maturity model**: *Spec-First* (spec per task, discarded after implementation), *Spec-Anchored* (spec as a living document, changes start from it), and *Spec-as-Source* (spec is the only artifact, code is compiled output). At the most aggressive end, **Tessl** ($125M raised) is building toward spec-as-source: *"code becomes a byproduct of specification"*. Golubev's conclusion: *"SDD is not a fad and not waterfall in markdown… AI agents generate good code when the task is well-defined. Without a spec — you're rolling the dice"*. The article references the **METR study (July 2025)** finding that developers using AI were **19% slower** on real-world tasks, attributing the problem to *"debugging loops from unstructured prompts"* — a finding that has become a recurring justification for the SDD movement. [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) + +### Community Tools and Documentation Improvements + +The **Spec Kit Assistant VS Code extension**, a third-party tool providing a graphical interface for running Spec Kit commands, was formally recognized as a "Community Friend" and added to the project README (#1944, #1956). The README underwent significant reorganization during the month: the team consolidated **Community Friends** sections (#1958), moved the **community extensions table** into the main README for discoverability (#1959), added a **community presets** section (#1960), an **AIDE extension demo** (#1943), and updated the publishing guide with Category and Effect columns to help extension authors categorize submissions (#1913). An **Extension Comparison Guide** was also published (#1897) and a manual testing guide for slash command validation (#1955). The team added multiple technology-specific walkthroughs: a **Java brownfield walkthrough** (#1820), a **Go/React brownfield dashboard walkthrough** (#1868), and the **Spring Boot pirate-speak preset** demo (#1878), supplementing the walkthroughs for .NET CLI, Spring Boot + React, and ASP.NET CMS that were committed in late February/early March. [\[github.com\]](https://github.com/github/spec-kit/releases) + +A notable community project appeared on GitHub: **speckit-pipeline** by iandeherdt, described as *"a pipeline on top of spec-kit to automate the design and build process with an evaluation step"*. This tool installs specialized Claude Code agents for a **design loop** (designer + design-critic agents iterating in a real browser) and a **build loop** (developer + evaluator agents verifying implementations against acceptance criteria). Both loops produce structured feedback and iterate up to 5 cycles per sprint until the work passes a quality gate. While small (4 commits, 0 stars as of retrieval), it represents the kind of higher-order automation that the community is building atop Spec Kit's foundation. The official Spec Kit repository has an open issue (#1966) requesting a built-in pipeline command for automated end-to-end workflow execution, suggesting this pattern may eventually be incorporated into the core. [\[github.com\]](https://github.com/iandeherdt/speckit-pipeline) + +A public **Microsoft Learn** training module titled *"Implement Spec-Driven Development using the GitHub Spec Kit"* was also available during March — a 3-hour, 13-unit, intermediate-level course covering practical SDD workflows with Spec Kit, providing an onboarding path for enterprise developers encountering SDD for the first time. + +## SDD Ecosystem & Industry Trends + +### The "Vibe Coding Is Dead" Narrative + +On March 20, *ByteIota* published *"Spec-Driven Development Kills 'Vibe Coding'"*, reporting that AWS developers were pushing SDD as the new standard for AI-assisted coding. The article cited **over 100,000 developers** adopting SDD approaches in the first five days of tool previews, AWS demonstrating a two-week feature completed in **two days** using Kiro IDE with structured specs, and World Economic Forum research indicating **65% of developers** expect their role to change around spec-first workflows in 2026. Spec Kit received a direct recommendation as a free, open-source option supporting **22+ AI platforms**. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) + +The article gave equal space to critics. *Marmelab* called SDD *"the exact mistakes Agile was designed to solve."* A controlled test by *Isoform* found SDD took **33 minutes** to produce **689 lines of code** versus **8 minutes with iterative prompting**, with no measured quality improvement. The emerging consensus favored **hybrid approaches** — a Red Hat developer recommendation captured the middle ground: *"Use the vibes to explore. Use specifications to build."* Reported success cases included Google's migrations (**50% time reduction**), Airbnb migrating **3,500 test files** at a **15× speedup**, and API-first teams achieving **75% cycle time reduction**. But solo developers consistently reported overhead exceeding benefit. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) + +The ByteIota piece was not isolated. Within a two-week window, independent articles appeared from **Shimon Ifrah** (*"Vibe Coding Is Dead"*), **Raul Proenza** at Cox Automotive (*"Welcome to Agentic Engineering"*), **CGI** (*"From vibe coding to intent engineering"*), and **Vishal Mysore** on Medium (*"A Map of 30+ Agentic Coding Frameworks"*). ByteIota also raised an underappreciated concern: if specifications replace coding as the primary activity, **how do junior developers build the judgment needed to write good specs or review AI-generated code?** No proven onboarding model exists yet for a spec-first world. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) + +### Competitive Landscape and Comparisons + +The SDD tool ecosystem expanded rapidly. Beyond the frameworks catalogued by Golubev, March saw deeper public analysis of how tools compare. + +On March 31, **Augment Code** published *"Intent vs GitHub Spec Kit (2026): Platform or Framework?"* The analysis framed Spec Kit as an agent-agnostic framework producing **plain Markdown files identical across agents**, while Intent (Augment Code's own product) offers **"living specs"** that auto-update as agents complete work. The core tradeoff: Spec Kit's strength is **portability** across 22+ agents; Intent's is **deeper native integration** with automated drift detection. The comparison surfaced the **drift problem** as a key architectural concern — Spec Kit's specs can become stale post-implementation, and while community extensions (Retrospective, Verify, Sync) address this post-facto, native real-time drift detection is not yet in core. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) + +The broader competitive landscape continued to evolve. **OpenSpec** (Fission AI) remained at ~29,300 stars, **BMAD-METHOD** grew to ~41,000 stars, and **Tessl** continued in private beta pursuing spec-as-source. AWS's **Kiro** delivered the two-day implementation result cited by ByteIota. While Spec Kit leads in GitHub popularity and agent breadth, alternatives are differentiating on orchestration depth (Intent, BMAD), enforced planning discipline (cc-sdd), decision audit trails (QuintCode), and spec-as-source vision (Tessl). [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) + +## Roadmap + +Areas under discussion or in progress for future development: + +- **Spec lifecycle management** -- supporting longer-lived specifications that evolve across multiple iterations. The Augment Code comparison and community commentary highlighted "spec drift" as a key concern. The Archive & Reconcile extension (#1844) is a community step; a core solution is expected to be a focus area. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) [\[github.com\]](https://github.com/github/spec-kit/releases) +- **CI/CD integration** -- incorporating Spec Kit verification into pull request workflows and failing builds when specs are out of alignment. The Jira and Azure DevOps extensions (#1764, #1734) are a first step. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **End-to-end workflow automation** -- an open issue (#1966) proposes a built-in pipeline command. The community-built **speckit-pipeline** by iandeherdt already demonstrates multi-agent loops with browser verification. [\[github.com\]](https://github.com/iandeherdt/speckit-pipeline) +- **Continued agent expansion** -- seven new agents were added in March alone. The agent-agnostic design means support for emerging tools can be added by anyone. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) +- **Experience simplification** -- the preset system, custom workflows, and growing walkthrough library lower the learning curve, but extension discoverability will need a more robust solution as the catalog grows. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **Toward a stable release** -- nine releases in one month reflects pre-1.0 momentum. Reaching 1.0 will require stabilizing the extension and preset APIs and ensuring backward compatibility across the agent and extension surface area. [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md) + + From 4deb90f4f5790f88953db7ff2f40b6b96d570386 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:03:29 -0500 Subject: [PATCH 003/184] fix: restore alias compatibility for community extensions (#2110) (#2125) Relax alias validation in _collect_manifest_command_names() to only enforce the 3-part speckit.{ext}.{cmd} pattern on primary command names. Aliases retain type and duplicate checking but are otherwise free-form, restoring pre-#1994 behavior. This unblocks community extensions (e.g. spec-kit-verify) that use 2-part aliases like 'speckit.verify'. Fixes #2110 --- src/specify_cli/extensions.py | 38 +++++++++++++++++++---------------- tests/test_extensions.py | 8 ++++---- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 6d7b7c1199..da1a5f4472 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -523,10 +523,11 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st """Collect command and alias names declared by a manifest. Performs install-time validation for extension-specific constraints: - - commands and aliases must use the canonical `speckit.{extension}.{command}` shape - - commands and aliases must use this extension's namespace + - primary commands must use the canonical `speckit.{extension}.{command}` shape + - primary commands must use this extension's namespace - command namespaces must not shadow core commands - duplicate command/alias names inside one manifest are rejected + - aliases are validated for type and uniqueness only (no pattern enforcement) Args: manifest: Parsed extension manifest @@ -563,23 +564,26 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st f"{kind.capitalize()} for command '{primary_name}' must be a string" ) - match = EXTENSION_COMMAND_NAME_PATTERN.match(name) - if match is None: - raise ValidationError( - f"Invalid {kind} '{name}': " - "must follow pattern 'speckit.{extension}.{command}'" - ) + # Enforce canonical pattern only for primary command names; + # aliases are free-form to preserve community extension compat. + if kind == "command": + match = EXTENSION_COMMAND_NAME_PATTERN.match(name) + if match is None: + raise ValidationError( + f"Invalid {kind} '{name}': " + "must follow pattern 'speckit.{extension}.{command}'" + ) - namespace = match.group(1) - if namespace != manifest.id: - raise ValidationError( - f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'" - ) + namespace = match.group(1) + if namespace != manifest.id: + raise ValidationError( + f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'" + ) - if namespace in CORE_COMMAND_NAMES: - raise ValidationError( - f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'" - ) + if namespace in CORE_COMMAND_NAMES: + raise ValidationError( + f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'" + ) if name in declared_names: raise ValidationError( diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 9d4df6a9a1..c5aed03dcf 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -686,8 +686,8 @@ def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_ with pytest.raises(ValidationError, match="conflicts with core command namespace"): manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - def test_install_rejects_alias_without_extension_namespace(self, temp_dir, project_dir): - """Install should reject legacy short aliases that can shadow core commands.""" + def test_install_accepts_short_alias(self, temp_dir, project_dir): + """Install should accept legacy short aliases for community extension compat.""" import yaml ext_dir = temp_dir / "alias-shortcut" @@ -718,8 +718,8 @@ def test_install_rejects_alias_without_extension_namespace(self, temp_dir, proje (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") manager = ExtensionManager(project_dir) - with pytest.raises(ValidationError, match="Invalid alias 'speckit.shortcut'"): - manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + # Should not raise — short aliases are allowed + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) def test_install_rejects_namespace_squatting(self, temp_dir, project_dir): """Install should reject commands and aliases outside the extension namespace.""" From ac6714de31bab4fbe974942613bbc6c7daaf7390 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:21:50 -0500 Subject: [PATCH 004/184] docs: lighten March 2026 newsletter for readability (#2127) - Remove PR/issue number references throughout - Shorten summary table cells - Break version wall-of-text into shorter per-version paragraphs - Trim blog post summaries to key insights - Condense community tools and industry coverage sections - Merge competitive landscape subsections --- newsletters/2026-March.md | 54 +++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/newsletters/2026-March.md b/newsletters/2026-March.md index 1d42443dcd..d97ca3960f 100644 --- a/newsletters/2026-March.md +++ b/newsletters/2026-March.md @@ -1,36 +1,36 @@ # Spec Kit - March 2026 Newsletter -This edition covers Spec Kit activity in March 2026. Versions v0.2.0 through v0.4.3 shipped during the month — nine releases — introducing major capabilities including simultaneous multi-catalog extension support, a pluggable preset system, air-gapped offline deployment, and automatic skill registration for extensions. Seven new AI coding assistants were integrated, bringing total platform support past 22. Community activity included over twenty new extensions, independent walkthroughs and blog posts, and a wave of industry coverage debating whether "vibe coding" is dead. A category summary is in the table below, followed by details. +This edition covers Spec Kit activity in March 2026. Nine releases shipped (v0.2.0 through v0.4.3), introducing a pluggable preset system, air-gapped deployment, automatic skill registration, and seven new AI agent integrations. The community extension catalog grew past 20 entries, independent walkthroughs and blog posts proliferated, and industry coverage debated whether "vibe coding" is dead. A summary is in the table below, followed by details. | **Spec Kit Core (Mar 2026)** | **Community & Content** | **SDD Ecosystem & Next** | | --- | --- | --- | -| Versions **v0.2.0** through **v0.4.3** shipped with major features: multi-catalog extensions, pluggable presets, air-gapped deployment, and auto-registration of extension skills. Seven new agents added (Tabnine CLI, Kimi Code, Mistral Vibe, Junie, iFlow, Trae, Pi). The repo grew from ~71k to **72,700 stars** by March 20. [\[github.com\]](https://github.com/github/spec-kit/releases) | Walkthroughs by Tiago Valverde, Alfredo Perez, and Sergey Golubev covered SDD in practice. Over 20 community extensions reached the catalog. The Spec Kit Assistant VS Code extension was recognized as a Community Friend. A Microsoft Learn training module on SDD with Spec Kit was available. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) | ByteIota reported AWS pushing SDD as the new standard; multiple independent articles declared "vibe coding" dead. Augment Code published a detailed Spec Kit vs. Intent comparison. Competitors differentiate on orchestration depth and living specs; Spec Kit leads in agent breadth and portability. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) | +| Nine releases shipped with major features: multi-catalog extensions, pluggable presets, air-gapped deployment, and auto-registration of extension skills. Seven new agents added. The repo grew from ~71k to **82,616 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Walkthroughs by Tiago Valverde, Alfredo Perez, and Sergey Golubev. Over 20 community extensions. The Spec Kit Assistant VS Code extension was recognized as a Community Friend. A Microsoft Learn training module became available. | ByteIota reported AWS pushing SDD as the new standard. Augment Code published a Spec Kit vs. Intent comparison. Competitors differentiate on orchestration depth and living specs; Spec Kit leads in agent breadth and portability. | *** ## Spec Kit Project Updates -### Three Major Versions and Six Patches +### Releases Overview -**v0.2.0** (released March 10) was the month's opening milestone. Its headline feature was **simultaneous multi-catalog support** (PR #1720), enabling users to activate both the core and community extension catalogs at the same time — a prerequisite for the modular ecosystem that would flourish throughout the rest of the month. The release bundled the first new agent integrations of March — **Tabnine CLI** (#1503) and **Kimi Code CLI** (#1790) — along with four community extensions: **Understanding** (#1778), **Ralph** (#1780), **Review** (#1775), and **Fleet Orchestrator** (#1771). Tooling improvements included `.extensionignore` support for excluding files during extension installation (#1781) and **Codex extension command registration** (#1767). Patch **v0.2.1** followed to fix broken quickstart links (#1759/#1797), add catalog CLI help documentation (#1793/#1794), and use quiet checkout to suppress git exceptions (#1792). The February 2026 newsletter was also committed as part of v0.2.1 (#1812). [\[github.com\]](https://github.com/github/spec-kit/blob/main/newsletters/2026-February.md) [\[github.com\]](https://github.com/github/spec-kit/releases) +**v0.2.0** (March 10) opened the month with **simultaneous multi-catalog support**, enabling both core and community extension catalogs at the same time. It added **Tabnine CLI** and **Kimi Code CLI** agents, four community extensions (Understanding, Ralph, Review, Fleet Orchestrator), and `.extensionignore` support. Patch **v0.2.1** fixed broken quickstart links and added catalog CLI help. [\[github.com\]](https://github.com/github/spec-kit/releases) -**v0.3.0** (mid-March) delivered one of the most anticipated features: a **pluggable preset system** with catalog, resolver, and skills propagation (#1787). Presets let teams override Spec Kit's default templates and commands with their own conventions — a mechanism that Thulasi Rajasekaran described on LinkedIn as "the layer that makes AI-assisted development governable". The system supports priority-based stacking: an organization can layer an enterprise-standards preset beneath a team-style preset, with lower priority numbers winning conflicts. Version 0.3.0 also added a **/selftest.extension** core extension for testing other extensions against the framework (#1758), **RFC-aligned catalog integration** quality-of-life improvements (#1776), and hardened bash scripts against shell injection (#1809). On the agent side, v0.3.0 added **Mistral Vibe CLI** (#1725), migrated **Qwen Code CLI** from TOML to Markdown format (#1589/#1730), and deprecated explicit command support for the Antigravity (agy) agent (#1798/#1808). Several new community extensions arrived, including **DocGuard CDD** (#1838), **Archive & Reconcile** (#1844), **specify-status** (#1837), and **specify-doctor** (#1828). [\[github.com\]](https://github.com/github/spec-kit/releases) [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) +**v0.3.0** (mid-March) delivered the **pluggable preset system** with catalog, resolver, and skills propagation. Presets let teams override default templates with their own conventions, using priority-based stacking. The release also added a **/selftest.extension** for testing extensions, **Mistral Vibe CLI**, migrated **Qwen Code CLI** from TOML to Markdown, and hardened bash scripts against shell injection. New community extensions included DocGuard CDD, Archive & Reconcile, specify-status, and specify-doctor. [\[github.com\]](https://github.com/github/spec-kit/releases) -Patches came rapidly. **v0.3.1** wired **before/after hook events** into the specify and plan templates (#1886), added **JSONC deep-merge** support for `settings.json` (#1874), added the **Trae IDE** agent (#1817), and introduced priority-based resolution for both extensions and presets (#1855). A greenfield **Spring Boot pirate-speak preset** demo was published in the README (#1878), and a **Go/React brownfield walkthrough** using GitHub Copilot CLI was added to community walkthroughs (#1868). **v0.3.2** added four more new agent integrations — **JetBrains Junie** (#1831), **iFlow CLI** (#1875), and **Pi Coding Agent** (#1853) — plus migrated Codex/agy init to a native skills workflow (#1906). It also shipped a **preset submission template** (#1910) and an **Extension Comparison Guide** (#1897) to help the growing community navigate overlapping extensions. Additional community extensions added in this cycle included **verify-tasks** (#1871), **conduct** (#1908), **cognitive-squad** (#1870, updated to Triadic Model), **speckit-utils** (#1896), **spec-kit-iterate** (#1887), and **spec-kit-learn** (#1883). DocGuard received three version updates in quick succession (v0.9.8, v0.9.10, v0.9.11). [\[github.com\]](https://github.com/github/spec-kit/releases) +**v0.3.1** added before/after hook events, JSONC deep-merge for `settings.json`, and the **Trae IDE** agent. **v0.3.2** added **Junie**, **iFlow CLI**, and **Pi Coding Agent**, plus a preset submission template and an Extension Comparison Guide. Community extensions continued arriving: verify-tasks, conduct, cognitive-squad, speckit-utils, spec-kit-iterate, and spec-kit-learn. [\[github.com\]](https://github.com/github/spec-kit/releases) -**v0.4.0** (late March) introduced the month's headline usability feature: **auto-registration of extension skills** (#1840), so that any installed extension's commands are automatically exposed as agent skills without extra configuration. It also delivered **air-gapped/offline deployment** by embedding the core template pack directly in the CLI wheel (#1803), enabling Spec Kit to function in restricted environments with no internet access. A **timestamp-based branch naming** option was added for `specify init` (#1911) to better support parallel feature development. The YAML I/O layer was fixed to use `allow_unicode=True` and `encoding="utf-8"` (#1936), and the stale-issue GitHub Action was increased to 250 operations per run (#1922). New community extensions in this cycle included **Checkpoint** (#1947). [\[github.com\]](https://github.com/github/spec-kit/releases) +**v0.4.0** (late March) introduced **auto-registration of extension skills** — installed extensions' commands are now automatically exposed as agent skills. It also delivered **air-gapped/offline deployment** by embedding core templates in the CLI wheel and added timestamp-based branch naming. [\[github.com\]](https://github.com/github/spec-kit/releases) -Three rapid patches closed the month. **v0.4.1** fixed a missing **Assumptions section** in the spec template (#1939) and prioritized the `.specify` directory over the parent git root for **repo root detection** (#1933). **v0.4.2** was the month's most documentation-heavy release: it added **AIDE, Extensify, and Presetify** to the community catalog (#1961), moved the **community extensions table into the main README** for discoverability (#1959), added a **community presets** section (#1960), consolidated **Community Friends** sections (#1958), and formally recognized the **Spec Kit Assistant VS Code extension** (#1944). It also shipped a manual testing guide for slash command validation (#1955) and renamed "NFR" references to "success criteria" in the analyze and clarify commands (#1935). **v0.4.3** wrapped up the month by unifying Kimi/Codex skill naming and migrating legacy dotted directory names (#1971), and replacing the null-conditional operator in PowerShell scripts to restore **PowerShell 5.1 compatibility** (#1975). [\[github.com\]](https://github.com/github/spec-kit/releases) +Three patches closed the month. **v0.4.1** fixed a missing Assumptions section in the spec template and improved repo root detection. **v0.4.2** added AIDE, Extensify, and Presetify to the community catalog, moved the community extensions table into the main README, and recognized the **Spec Kit Assistant VS Code extension** as a Community Friend. **v0.4.3** unified skill naming conventions and restored **PowerShell 5.1 compatibility**. [\[github.com\]](https://github.com/github/spec-kit/releases) ### Bug Fixes and Security Hardening -Security and stability received substantial attention. The most significant fix was **shell injection hardening** of bash scripts (#1809), which addressed a class of potential injection vulnerabilities where unsanitized values from git branch names or environment variables could be passed through to shell commands. The v0.3.1 release followed up with additional bash escape and compatibility improvements (#1869). **Branch numbering** was overhauled: the old per-short-name detection scheme caused conflicts, so Spec Kit switched to **global branch numbering** (#1757) for consistent sequencing across feature branches. A **quiet git checkout** fix suppressed exceptions during branching (#1792), and a **git fetch stdout leak** was suppressed in multi-remote environments (#1876). **JSON control characters** were encoded as `\uXXXX` instead of being silently stripped (#1872). Explicit **PowerShell positional binding** was added to `create-new-feature` parameters (#1885), and the Codex native skills fallback was refreshed with legacy prompt suppression (#1930). [\[github.com\]](https://github.com/github/spec-kit/releases) +The most significant fix was **shell injection hardening** of bash scripts, addressing potential vulnerabilities from unsanitized git branch names and environment variables. Other fixes included switching to **global branch numbering** for consistent sequencing, suppressing git checkout exceptions and fetch stdout leaks, properly encoding JSON control characters, and adding explicit PowerShell positional binding. [\[github.com\]](https://github.com/github/spec-kit/releases) -### The Extension Catalog at Scale +### The Extension Ecosystem -By late March, over **20 community extensions** had been built for Spec Kit. Thulasi Rajasekaran's LinkedIn article on March 20, titled *"The Feature That Turns Spec Kit Into a Platform: Extensions & Presets,"* provided a thorough analysis of what makes this ecosystem significant. Rajasekaran highlighted several standout extensions: **Conduct**, which orchestrates SDD phases by delegating to sub-agents to solve "context pollution" (where a single agent accumulates so many tokens that quality degrades); **Verify Tasks**, which scans task lists for "phantom completions" — tasks marked done with no real code behind them; **Understanding**, which runs 31 deterministic quality metrics against specifications based on IEEE/ISO standards ("like a linter for English"); and the **Jira and Azure DevOps integrations**, which auto-create work items from specs and tasks. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) +By late March, over **20 community extensions** had been built for Spec Kit. Thulasi Rajasekaran's LinkedIn article *"The Feature That Turns Spec Kit Into a Platform"* highlighted standouts: **Conduct** (orchestrates SDD phases via sub-agents to avoid context pollution), **Verify Tasks** (catches "phantom completions" — tasks marked done with no real code), **Understanding** (31 quality metrics against specs based on IEEE/ISO standards), and the **Jira and Azure DevOps integrations**. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) -Rajasekaran argued that the real significance of presets is not the mechanism itself — which is "almost comically simple" (a stack of Markdown files with a priority order) — but what it enables: the same machinery that turned "User Stories" into "Crew Tales" in the pirate-speak demo could turn them into compliance requirements with traceability IDs, add mandatory threat-model sections to every plan, or enforce TDD by requiring test tasks before implementation tasks. The pirate-speak preset, which Rajasekaran described in detail, was built by the Spec Kit maintainer using Spring Boot 4 and produced a fully functional application where every artifact — headings, paragraphs, status updates — was rendered in pirate prose. Organizations can curate which extensions are available to developers by hosting custom catalog URLs. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) +Rajasekaran argued the real significance of presets is what they enable: the same machinery that turned "User Stories" into pirate-speak "Crew Tales" could enforce compliance requirements, add mandatory threat-model sections, or require test tasks before implementation tasks. Organizations can curate available extensions by hosting custom catalog URLs. [\[linkedin.com\]](https://www.linkedin.com/pulse/feature-turns-spec-kit-platform-extensions-presets-rajasekaran-3ejgc) ## Community & Content @@ -38,39 +38,33 @@ Rajasekaran argued that the real significance of presets is not the mechanism it March produced a wave of independent content as developers explored SDD in practice. -**Tiago Valverde** published *"Spec-Driven Development in Practice: A Walkthrough with Spec Kit"* on March 14 (12-minute read). The post documents building an Instagram-style photo mural feature on a personal blog using the full Spec Kit workflow — from constitution setup through specification, clarification, planning, task breakdown, and implementation. Valverde contrasts the structured approach with previous ad-hoc prompting: while directly prompting Claude worked for small changes, for anything complex Valverde *"kept running into the same problems: scope creep mid-session, ambiguous requirements only noticed after implementation, and no artifact left behind to explain why a decision was made"*. The walkthrough includes actual Claude command syntax, expected file structures, and practical advice. Valverde recommends being *"overly specific in the initial prompt"* and to *"immediately review `spec.md`"* and add missing requirements before proceeding to planning. The clarify step is highlighted as particularly valuable — it surfaces guided questions with recommended options and identifies *"decisions that materially change complexity"*. On the broader philosophy, Valverde frames SDD as essential for production work: *"engineers still need to guarantee code quality, meet security requirements, keep documentation up to date, and keep all the stakeholders in sync with the project status and health"*. Valverde also published a shorter companion piece on March 8 titled *"The Shift from Vibe Coding to Spec-Driven Development,"* describing the broader industry trend. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) +**Tiago Valverde** published *"Spec-Driven Development in Practice: A Walkthrough with Spec Kit"* on March 14. He documents building an Instagram-style photo mural feature using the full Spec Kit workflow, contrasting it with previous ad-hoc prompting: while directly prompting Claude worked for small changes, complex work led to scope creep, ambiguous requirements discovered too late, and no artifacts left behind. Valverde recommends being specific in the initial prompt, reviewing `spec.md` immediately, and highlights the clarify step as particularly valuable. A shorter companion piece, *"The Shift from Vibe Coding to Spec-Driven Development,"* appeared on March 8. [\[tiagovalverde.com\]](https://www.tiagovalverde.com/posts/spec-driven-development-in-practice-a-walkthrough-with-spec-kit) -**Alfredo Perez** published *"Build Your Own SDD Workflow"* on March 21, taking a deliberately contrarian approach to Spec Kit's thoroughness. Perez praises spec-driven development in principle (*"planning before you code catches bad assumptions early, keeps scope honest, and gives the AI enough context to write code you'd actually ship"*) but argues the standard seven-step workflow carries too much ceremony for smaller tasks: *"When you're adding a component or fixing a bug, that's a lot of overhead for a Tuesday afternoon"*. Perez's solution is a lean **4-step custom workflow** — `specify → plan → tasks → implement` — dropping constitution, clarify, and review. The rationale: coding rules belong in `CLAUDE.md` once (not re-checked per feature), clarification can happen iteratively rather than as a formal upfront step, and review can be added back when warranted. The custom workflow is wired into the **SpecKit Companion** VS Code extension via a `speckit.customWorkflows` configuration in `.vscode/settings.json`, giving users a visual sidebar where each phase appears as a clickable step. Perez walks through a real feature implementation — adding a home page and navigation bar — showing each phase in action with screenshots of the SpecKit Companion panel. The article highlights an important tradeoff: **full rigor vs. lightweight adoption**. Not every project needs all seven steps, and Spec Kit's extensible design accommodates both extremes. Perez also presented this workflow live at an **Angular Community Meetup** on March 25 titled *"Create Your Own AI Workflow"*. [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) +**Alfredo Perez** published *"Build Your Own SDD Workflow"* on March 21, taking a deliberately contrarian approach. He praises SDD in principle but argues the full seven-step workflow carries too much ceremony for smaller tasks. His solution is a lean **4-step custom workflow** — `specify → plan → tasks → implement` — dropping constitution, clarify, and review, wired into the **SpecKit Companion** VS Code extension. The article highlights an important tradeoff: full rigor vs. lightweight adoption. Perez also presented this workflow at an **Angular Community Meetup** on March 25. [\[alfredo-perez.dev\]](https://www.alfredo-perez.dev/blog/2026-03-21-build-your-own-sdd-workflow) -**Sergey Golubev** of prodfeat.ai published *"20+ SDD Frameworks: A Catalog for AI Development"* on March 17, the result of two weeks of research across YouTube, Telegram, Medium, Augment Code, ThoughtWorks Radar, and GitHub repositories. The catalog organizes **20+ frameworks in 6 categories** and highlights three standouts. **BMAD-METHOD** (\~41,000 stars) simulates an entire agile team from AI roles — Analyst, PM, Architect, Scrum Master — and produces a full PRD with architecture and dev stories; Golubev calls it *"perfect for a team of 3-5 people"* but notes a high barrier to entry. **QuintCode + FPF** is notable for its ADI Cycle in 5 phases (hypothesize, verify logic, test, audit, decide), which preserves decision rationale — *"three months later you won't remember the reasoning."* In one case study, FPF + ChatGPT Pro produced a 52-page spec and 280 feature files in two evenings, and QuintCode chose Docker Swarm over Kubernetes through reasoned analysis. **cc-sdd** (\~2,880 stars) provides Kiro-style SDD commands for 8 tools (Claude Code, Cursor, Gemini CLI, Codex CLI, and 4 more), with an enforced workflow that *"won't let you skip planning"*. [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) +**Sergey Golubev** of prodfeat.ai published *"20+ SDD Frameworks: A Catalog for AI Development"* on March 17. The catalog organizes **20+ frameworks in 6 categories**, highlighting **BMAD-METHOD** (~41k stars, simulates an agile team from AI roles), **QuintCode + FPF** (preserves decision rationale via a 5-phase ADI Cycle), and **cc-sdd** (~2.9k stars, enforced SDD workflow for 8 tools). Golubev presents a three-level maturity model: *Spec-First* (spec per task, discarded after), *Spec-Anchored* (living document), and *Spec-as-Source* (spec is the only artifact). His conclusion: "SDD is not a fad… AI agents generate good code when the task is well-defined. Without a spec — you're rolling the dice." [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) -Golubev also presents a **three-level SDD maturity model**: *Spec-First* (spec per task, discarded after implementation), *Spec-Anchored* (spec as a living document, changes start from it), and *Spec-as-Source* (spec is the only artifact, code is compiled output). At the most aggressive end, **Tessl** ($125M raised) is building toward spec-as-source: *"code becomes a byproduct of specification"*. Golubev's conclusion: *"SDD is not a fad and not waterfall in markdown… AI agents generate good code when the task is well-defined. Without a spec — you're rolling the dice"*. The article references the **METR study (July 2025)** finding that developers using AI were **19% slower** on real-world tasks, attributing the problem to *"debugging loops from unstructured prompts"* — a finding that has become a recurring justification for the SDD movement. [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) +### Community Tools and Documentation -### Community Tools and Documentation Improvements +The **Spec Kit Assistant VS Code extension** was formally recognized as a Community Friend and added to the README. The README was reorganized: community extensions table moved into the main page for discoverability, a community presets section was added, and the publishing guide gained Category and Effect columns. New walkthroughs included Java brownfield, Go/React brownfield dashboard, and the Spring Boot pirate-speak preset demo. [\[github.com\]](https://github.com/github/spec-kit/releases) -The **Spec Kit Assistant VS Code extension**, a third-party tool providing a graphical interface for running Spec Kit commands, was formally recognized as a "Community Friend" and added to the project README (#1944, #1956). The README underwent significant reorganization during the month: the team consolidated **Community Friends** sections (#1958), moved the **community extensions table** into the main README for discoverability (#1959), added a **community presets** section (#1960), an **AIDE extension demo** (#1943), and updated the publishing guide with Category and Effect columns to help extension authors categorize submissions (#1913). An **Extension Comparison Guide** was also published (#1897) and a manual testing guide for slash command validation (#1955). The team added multiple technology-specific walkthroughs: a **Java brownfield walkthrough** (#1820), a **Go/React brownfield dashboard walkthrough** (#1868), and the **Spring Boot pirate-speak preset** demo (#1878), supplementing the walkthroughs for .NET CLI, Spring Boot + React, and ASP.NET CMS that were committed in late February/early March. [\[github.com\]](https://github.com/github/spec-kit/releases) +A notable community project appeared: **speckit-pipeline** by iandeherdt — a pipeline atop Spec Kit with a **design loop** (designer + critic agents iterating in a browser) and a **build loop** (developer + evaluator agents verifying against acceptance criteria). An open issue (#1966) requests a built-in pipeline command, suggesting this pattern may eventually reach core. -A notable community project appeared on GitHub: **speckit-pipeline** by iandeherdt, described as *"a pipeline on top of spec-kit to automate the design and build process with an evaluation step"*. This tool installs specialized Claude Code agents for a **design loop** (designer + design-critic agents iterating in a real browser) and a **build loop** (developer + evaluator agents verifying implementations against acceptance criteria). Both loops produce structured feedback and iterate up to 5 cycles per sprint until the work passes a quality gate. While small (4 commits, 0 stars as of retrieval), it represents the kind of higher-order automation that the community is building atop Spec Kit's foundation. The official Spec Kit repository has an open issue (#1966) requesting a built-in pipeline command for automated end-to-end workflow execution, suggesting this pattern may eventually be incorporated into the core. [\[github.com\]](https://github.com/iandeherdt/speckit-pipeline) - -A public **Microsoft Learn** training module titled *"Implement Spec-Driven Development using the GitHub Spec Kit"* was also available during March — a 3-hour, 13-unit, intermediate-level course covering practical SDD workflows with Spec Kit, providing an onboarding path for enterprise developers encountering SDD for the first time. +A public **Microsoft Learn** training module, *"Implement Spec-Driven Development using the GitHub Spec Kit"* (3 hours, 13 units), provided an onboarding path for enterprise developers. ## SDD Ecosystem & Industry Trends ### The "Vibe Coding Is Dead" Narrative -On March 20, *ByteIota* published *"Spec-Driven Development Kills 'Vibe Coding'"*, reporting that AWS developers were pushing SDD as the new standard for AI-assisted coding. The article cited **over 100,000 developers** adopting SDD approaches in the first five days of tool previews, AWS demonstrating a two-week feature completed in **two days** using Kiro IDE with structured specs, and World Economic Forum research indicating **65% of developers** expect their role to change around spec-first workflows in 2026. Spec Kit received a direct recommendation as a free, open-source option supporting **22+ AI platforms**. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) - -The article gave equal space to critics. *Marmelab* called SDD *"the exact mistakes Agile was designed to solve."* A controlled test by *Isoform* found SDD took **33 minutes** to produce **689 lines of code** versus **8 minutes with iterative prompting**, with no measured quality improvement. The emerging consensus favored **hybrid approaches** — a Red Hat developer recommendation captured the middle ground: *"Use the vibes to explore. Use specifications to build."* Reported success cases included Google's migrations (**50% time reduction**), Airbnb migrating **3,500 test files** at a **15× speedup**, and API-first teams achieving **75% cycle time reduction**. But solo developers consistently reported overhead exceeding benefit. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) - -The ByteIota piece was not isolated. Within a two-week window, independent articles appeared from **Shimon Ifrah** (*"Vibe Coding Is Dead"*), **Raul Proenza** at Cox Automotive (*"Welcome to Agentic Engineering"*), **CGI** (*"From vibe coding to intent engineering"*), and **Vishal Mysore** on Medium (*"A Map of 30+ Agentic Coding Frameworks"*). ByteIota also raised an underappreciated concern: if specifications replace coding as the primary activity, **how do junior developers build the judgment needed to write good specs or review AI-generated code?** No proven onboarding model exists yet for a spec-first world. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) +*ByteIota* published *"Spec-Driven Development Kills 'Vibe Coding'"* on March 20, reporting AWS pushing SDD as the new standard. Key claims: over 100,000 developers adopting SDD approaches in early tool previews, AWS demonstrating a two-week feature completed in two days using Kiro IDE, and WEF research indicating 65% of developers expect their role to shift toward spec-first workflows in 2026. [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) -### Competitive Landscape and Comparisons +Critics got equal space. *Marmelab* called SDD "the exact mistakes Agile was designed to solve." An *Isoform* controlled test found SDD took 33 minutes for 689 lines vs. 8 minutes with iterative prompting, with no measured quality improvement. The emerging consensus favored hybrids — a Red Hat developer captured it: "Use the vibes to explore. Use specifications to build." Other independent articles appeared from Shimon Ifrah, Raul Proenza (Cox Automotive), CGI, and Vishal Mysore. ByteIota also raised an underappreciated concern: if specs replace coding, how do juniors build the judgment to write good specs or review AI-generated code? [\[byteiota.com\]](https://byteiota.com/spec-driven-development-kills-vibe-coding-march-2026/) -The SDD tool ecosystem expanded rapidly. Beyond the frameworks catalogued by Golubev, March saw deeper public analysis of how tools compare. +### Competitive Landscape -On March 31, **Augment Code** published *"Intent vs GitHub Spec Kit (2026): Platform or Framework?"* The analysis framed Spec Kit as an agent-agnostic framework producing **plain Markdown files identical across agents**, while Intent (Augment Code's own product) offers **"living specs"** that auto-update as agents complete work. The core tradeoff: Spec Kit's strength is **portability** across 22+ agents; Intent's is **deeper native integration** with automated drift detection. The comparison surfaced the **drift problem** as a key architectural concern — Spec Kit's specs can become stale post-implementation, and while community extensions (Retrospective, Verify, Sync) address this post-facto, native real-time drift detection is not yet in core. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) +**Augment Code** published *"Intent vs GitHub Spec Kit (2026): Platform or Framework?"* on March 31. The core tradeoff: Spec Kit's strength is **portability** across 22+ agents; Intent offers **living specs** with automated drift detection. The comparison surfaced spec drift as a key architectural concern — Spec Kit's specs can become stale post-implementation, and while community extensions address this, native real-time drift detection is not yet in core. [\[augmentcode.com\]](https://www.augmentcode.com/tools/intent-vs-github) -The broader competitive landscape continued to evolve. **OpenSpec** (Fission AI) remained at ~29,300 stars, **BMAD-METHOD** grew to ~41,000 stars, and **Tessl** continued in private beta pursuing spec-as-source. AWS's **Kiro** delivered the two-day implementation result cited by ByteIota. While Spec Kit leads in GitHub popularity and agent breadth, alternatives are differentiating on orchestration depth (Intent, BMAD), enforced planning discipline (cc-sdd), decision audit trails (QuintCode), and spec-as-source vision (Tessl). [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) +The broader landscape continued evolving. OpenSpec held ~29.3k stars, BMAD-METHOD grew to ~41k, and Tessl continued in private beta. While Spec Kit leads in GitHub popularity and agent breadth, alternatives differentiate on orchestration depth (Intent, BMAD), enforced discipline (cc-sdd), decision trails (QuintCode), and spec-as-source vision (Tessl). [\[prodfeat.ai\]](https://www.prodfeat.ai/en/blog/2026-03-17-sdd-frameworks-catalog) ## Roadmap From 3028a00b6e7b6ba568e49dcb816655894b5a633a Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 8 Apr 2026 22:59:41 +0500 Subject: [PATCH 005/184] Add Branch Convention community extension to catalog and README (#2128) Adds the spec-kit-branch-convention extension (3 commands, 1 hook) that enables configurable branch and folder naming with built-in presets for GitFlow, ticket-based, date-based, and custom patterns. Addresses community request in issue #407 (39+ upvotes). Co-authored-by: Claude Sonnet 4.6 --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index e996583429..cb3891d98a 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ The following community-contributed extensions are available in [`catalog.commun | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | +| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 6935624ee7..ee944cde59 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -106,6 +106,38 @@ "created_at": "2026-03-03T00:00:00Z", "updated_at": "2026-03-03T00:00:00Z" }, + "branch-convention": { + "name": "Branch Convention", + "id": "branch-convention", + "description": "Configurable branch and folder naming conventions for /specify with presets and custom patterns.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-branch-convention", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-branch-convention", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-branch-convention/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "branch", + "naming", + "convention", + "gitflow", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z" + }, "canon": { "name": "Canon", "id": "canon", From 838bd0fedc5820e01c23c3ac663a0ba9335fcb04 Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Wed, 8 Apr 2026 20:41:37 +0200 Subject: [PATCH 006/184] fix(git): surface checkout errors for existing branches (#2122) --- .../git/scripts/bash/create-new-feature.sh | 5 +- .../scripts/powershell/create-new-feature.ps1 | 8 ++- scripts/bash/create-new-feature.sh | 5 +- scripts/powershell/create-new-feature.ps1 | 8 ++- tests/test_timestamp_branches.py | 63 +++++++++++++++++++ 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index dfae29df73..c83e8c613f 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -366,8 +366,11 @@ if [ "$DRY_RUN" != true ]; then if [ "$ALLOW_EXISTING" = true ]; then if [ "$current_branch" = "$BRANCH_NAME" ]; then : - elif ! git checkout "$BRANCH_NAME" 2>/dev/null; then + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi exit 1 fi elif [ "$USE_TIMESTAMP" = true ]; then diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index 75a4e69814..ded6eaa72f 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -327,9 +327,13 @@ if (-not $DryRun) { if ($currentBranch -eq $branchName) { # Already on the target branch } else { - git checkout -q $branchName 2>$null | Out-Null + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { - Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + if ($switchBranchError) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" + } else { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + } exit 1 } } diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index f9ba9545df..1879647026 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -337,8 +337,11 @@ if [ "$DRY_RUN" != true ]; then if [ "$current_branch" = "$BRANCH_NAME" ]; then : # Otherwise switch to the existing branch instead of failing. - elif ! git checkout "$BRANCH_NAME" 2>/dev/null; then + elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then >&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again." + if [ -n "$switch_branch_error" ]; then + >&2 printf '%s\n' "$switch_branch_error" + fi exit 1 fi elif [ "$USE_TIMESTAMP" = true ]; then diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 3e7e525b86..2f23283fc4 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -315,9 +315,13 @@ if (-not $DryRun) { # Already on the target branch — nothing to do } else { # Otherwise switch to the existing branch instead of failing. - git checkout -q $branchName 2>$null | Out-Null + $switchBranchError = git checkout -q $branchName 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { - Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + if ($switchBranchError) { + Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())" + } else { + Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again." + } exit 1 } } diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 2c13853119..605ae48965 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -15,6 +15,12 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1" +EXT_CREATE_FEATURE = ( + PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" +) +EXT_CREATE_FEATURE_PS = ( + PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" +) COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" @@ -428,6 +434,43 @@ def test_allow_existing_no_git(self, no_git_dir: Path): ) assert result.returncode == 0, result.stderr + def test_allow_existing_surfaces_checkout_error(self, git_repo: Path): + """Checkout failures on an existing branch should include Git's stderr.""" + shared_file = git_repo / "shared.txt" + shared_file.write_text("base\n") + subprocess.run( + ["git", "add", "shared.txt"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "commit", "-m", "add shared file", "-q"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-b", "010-checkout-failure"], + cwd=git_repo, check=True, capture_output=True, + ) + shared_file.write_text("branch version\n") + subprocess.run( + ["git", "commit", "-am", "branch change", "-q"], + cwd=git_repo, check=True, capture_output=True, + ) + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo, check=True, capture_output=True, + ) + shared_file.write_text("uncommitted main change\n") + + result = run_script( + git_repo, "--allow-existing-branch", "--short-name", "checkout-failure", + "--number", "10", "Checkout failure", + ) + + assert result.returncode != 0, "checkout should fail with conflicting local changes" + assert "Failed to switch to existing branch '010-checkout-failure'" in result.stderr + assert "would be overwritten by checkout" in result.stderr + assert "shared.txt" in result.stderr + class TestAllowExistingBranchPowerShell: def test_powershell_supports_allow_existing_branch_flag(self): @@ -437,6 +480,26 @@ def test_powershell_supports_allow_existing_branch_flag(self): # Ensure the flag is referenced in script logic, not just declared assert "AllowExistingBranch" in contents.replace("-AllowExistingBranch", "") + def test_powershell_surfaces_checkout_errors(self): + """Static guard: PS script preserves checkout stderr on existing-branch failures.""" + contents = CREATE_FEATURE_PS.read_text(encoding="utf-8") + assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents + assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents + + +class TestGitExtensionParity: + def test_bash_extension_surfaces_checkout_errors(self): + """Static guard: git extension bash script preserves checkout stderr.""" + contents = EXT_CREATE_FEATURE.read_text(encoding="utf-8") + assert 'switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1)' in contents + assert "Failed to switch to existing branch '$BRANCH_NAME'" in contents + + def test_powershell_extension_surfaces_checkout_errors(self): + """Static guard: git extension PowerShell script preserves checkout stderr.""" + contents = EXT_CREATE_FEATURE_PS.read_text(encoding="utf-8") + assert "$switchBranchError = git checkout -q $branchName 2>&1 | Out-String" in contents + assert "exists but could not be checked out.`n$($switchBranchError.Trim())" in contents + # ── Dry-Run Tests ──────────────────────────────────────────────────────────── From 2972dec85c6370ef47275d3b4f072ecb31f7b36c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:48:36 -0500 Subject: [PATCH 007/184] =?UTF-8?q?feat:=20Git=20extension=20stage=202=20?= =?UTF-8?q?=E2=80=94=20GIT=5FBRANCH=5FNAME=20override,=20--force=20for=20e?= =?UTF-8?q?xisting=20dirs,=20auto-install=20tests=20(#1940)=20(#2117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) - Add GIT_BRANCH_NAME env var override to create-new-feature.sh/.ps1 for exact branch naming (bypasses all prefix/suffix generation) - Fix --force flag for 'specify init ' into existing directories - Add TestGitExtensionAutoInstall tests (auto-install, --no-git skip, commands registered) - Add TestFeatureDirectoryResolution tests (env var, feature.json, priority, branch fallback) - Document GIT_BRANCH_NAME in speckit.git.feature.md and specify.md * fix: remove unused Tuple import (ruff F401) * fix: address Copilot review feedback (#2117) - Fix timestamp regex ordering: check YYYYMMDD-HHMMSS before generic numeric prefix in both bash and PowerShell - Set BRANCH_SUFFIX in GIT_BRANCH_NAME override path so 244-byte truncation logic works correctly - Add 244-byte length check for GIT_BRANCH_NAME in PowerShell - Use existing_items for non-empty dir warning with --force - Skip git extension install if already installed (idempotent --force) - Wrap PowerShell feature.json parsing in try/catch for malformed JSON - Fix PS comment: 'prefix lookup' -> 'exact mapping via Get-FeatureDir' - Remove non-functional SPECIFY_SPEC_DIRECTORY from specify.md template * fix: address second round of Copilot review feedback (#2117) - Guard shutil.rmtree on init failure: skip cleanup when --force merged into a pre-existing directory (prevents data loss) - Bash: error on GIT_BRANCH_NAME >244 bytes instead of broken truncation - Fix malformed numbered list in specify.md (restore missing step 1) - Add claude_skills.exists() assert before iterdir() in test * fix: use UTF-8 byte count for 244-byte branch name limit (#2117) - Bash: use LC_ALL=C wc -c for byte length instead of ${#VAR} - PowerShell: use [System.Text.Encoding]::UTF8.GetByteCount() instead of .Length (UTF-16 code units) * fix: address third round of review feedback (#2117) - Update --dry-run help text in bash and PowerShell (branch name only) - Fix specify.md JSON example: use concrete path, not literal variable - Add TestForceExistingDirectory tests (merge + error without --force) - Add PowerShell Get-FeaturePathsEnv tests (env var + feature.json) * fix: normalize relative paths and fix Test-HasGit compat (#2117) - Bash common.sh: normalize SPECIFY_FEATURE_DIRECTORY and feature.json relative paths to absolute under repo root - PowerShell common.ps1: same normalization using IsPathRooted + Join-Path - PowerShell create-new-feature.ps1: call Test-HasGit without -RepoRoot for compatibility with core common.ps1 (no param) and git-common.ps1 (optional param with default) * test: add GIT_BRANCH_NAME automated tests for bash and PowerShell (#2117) - TestGitBranchNameOverrideBash: 5 tests (exact name, sequential prefix, timestamp prefix, overlong rejection, dry-run) - TestGitBranchNameOverridePowerShell: 4 tests (exact name, sequential prefix, timestamp prefix, overlong rejection) - Tests use extension scripts (not core) via new ext_git_repo and ext_ps_git_repo fixtures * fix: restore git init during specify init + review fixes (#2117) - Restore is_git_repo() and init_git_repo() functions removed in stage 2 - specify init now runs git init AND installs git extension (not just extension install alone) - Add is_dir() guard for non-here path to prevent uncontrolled error when target exists but is a file - Add python3 JSON fallback in common.sh for multi-line feature.json (grep pipeline fails on pretty-printed JSON without jq) * fix: use init_git_repo error_msg in failure output (#2117) * fix: ensure_executable_scripts also covers .specify/extensions/ (#2117) Extension .sh scripts (e.g. create-new-feature.sh, initialize-repo.sh) may lack execute bits after install. Scan both .specify/scripts/ and .specify/extensions/ for permission fixing. * fix: move chmod after extension install + sanitize error_msg (#2117) - ensure_executable_scripts() now runs after git extension install so extension .sh files get execute bits in the same init run - Sanitize init_git_repo error_msg to single line (replace newlines, truncate to 120 chars) to prevent garbled StepTracker output * fix: use tracker.error for git init/extension failures (#2117) Git init failure and extension install failure were reported as tracker.complete (showing green) even on error. Now track a git_has_error flag and call tracker.error when any step fails, so the UI correctly reflects the failure state. * fix: sanitize ext_err in git step tracker for consistent rendering (#2117) --- .../git/commands/speckit.git.feature.md | 25 +- .../git/scripts/bash/create-new-feature.sh | 123 ++++--- .../scripts/powershell/create-new-feature.ps1 | 100 +++--- scripts/bash/common.sh | 30 +- scripts/powershell/common.ps1 | 31 +- src/specify_cli/__init__.py | 190 ++++++----- templates/commands/specify.md | 74 ++-- tests/extensions/git/test_git_extension.py | 22 +- tests/integrations/test_cli.py | 141 ++++++++ tests/test_timestamp_branches.py | 318 ++++++++++++++++++ 10 files changed, 801 insertions(+), 253 deletions(-) diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 13a7d0784d..1a9c5e35da 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -4,7 +4,7 @@ description: "Create a feature branch with sequential or timestamp numbering" # Create Feature Branch -Create a new feature branch for the given specification. +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow. ## User Input @@ -14,10 +14,17 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## Environment Variable Override + +If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set: +- The script uses the exact value as the branch name, bypassing all prefix/suffix generation +- `--short-name`, `--number`, and `--timestamp` flags are ignored +- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name + ## Prerequisites - Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null` -- If Git is not available, warn the user and skip branch creation (spec directory will still be created) +- If Git is not available, warn the user and skip branch creation ## Branch Numbering Mode @@ -45,22 +52,16 @@ Run the appropriate script based on your platform: - Do NOT pass `--number` — the script determines the correct next number automatically - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - You must only ever run this script once per feature -- The JSON output will contain BRANCH_NAME and SPEC_FILE paths - -If the extension scripts are not found at the `.specify/extensions/git/` path, fall back to: -- **Bash**: `scripts/bash/create-new-feature.sh` -- **PowerShell**: `scripts/powershell/create-new-feature.ps1` +- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM` ## Graceful Degradation If Git is not installed or the current directory is not a Git repository: -- The script will still create the spec directory under `specs/` -- A warning will be printed: `[specify] Warning: Git repository not detected; skipped branch creation` -- The workflow continues normally without branch creation +- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation` +- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them ## Output The script outputs JSON with: -- `BRANCH_NAME`: The created branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) -- `SPEC_FILE`: Path to the created spec file +- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`) - `FEATURE_NUM`: The numeric or timestamp prefix used diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index c83e8c613f..286aaf7634 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -64,17 +64,21 @@ while [ $i -le $# ]; do echo "" echo "Options:" echo " --json Output in JSON format" - echo " --dry-run Compute branch name and paths without creating branches, directories, or files" + echo " --dry-run Compute branch name without creating the branch" echo " --allow-existing-branch Switch to branch if it already exists instead of failing" echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" echo " --help, -h Show this help message" echo "" + echo "Environment variables:" + echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + echo "" echo "Examples:" echo " $0 'Add user authentication system' --short-name 'user-auth'" echo " $0 'Implement OAuth2 integration for API' --number 5" echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + echo " GIT_BRANCH_NAME=my-branch $0 'feature description'" exit 0 ;; *) @@ -258,9 +262,6 @@ fi cd "$REPO_ROOT" SPECS_DIR="$REPO_ROOT/specs" -if [ "$DRY_RUN" != true ]; then - mkdir -p "$SPECS_DIR" -fi # Function to generate branch name with stop word filtering generate_branch_name() { @@ -301,45 +302,67 @@ generate_branch_name() { fi } -# Generate branch name -if [ -n "$SHORT_NAME" ]; then - BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if [ -n "${GIT_BRANCH_NAME:-}" ]; then + BRANCH_NAME="$GIT_BRANCH_NAME" + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern + if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then + FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+') + BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}" + else + FEATURE_NUM="$BRANCH_NAME" + BRANCH_SUFFIX="$BRANCH_NAME" + fi else - BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") -fi + # Generate branch name + if [ -n "$SHORT_NAME" ]; then + BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME") + else + BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION") + fi -# Warn if --number and --timestamp are both specified -if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then - >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" - BRANCH_NUMBER="" -fi + # Warn if --number and --timestamp are both specified + if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then + >&2 echo "[specify] Warning: --number is ignored when --timestamp is used" + BRANCH_NUMBER="" + fi -# Determine branch prefix -if [ "$USE_TIMESTAMP" = true ]; then - FEATURE_NUM=$(date +%Y%m%d-%H%M%S) - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" -else - if [ -z "$BRANCH_NUMBER" ]; then - if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) - elif [ "$DRY_RUN" = true ]; then - HIGHEST=$(get_highest_from_specs "$SPECS_DIR") - BRANCH_NUMBER=$((HIGHEST + 1)) - elif [ "$HAS_GIT" = true ]; then - BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") - else - HIGHEST=$(get_highest_from_specs "$SPECS_DIR") - BRANCH_NUMBER=$((HIGHEST + 1)) + # Determine branch prefix + if [ "$USE_TIMESTAMP" = true ]; then + FEATURE_NUM=$(date +%Y%m%d-%H%M%S) + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + else + if [ -z "$BRANCH_NUMBER" ]; then + if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true) + elif [ "$DRY_RUN" = true ]; then + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + elif [ "$HAS_GIT" = true ]; then + BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR") + else + HIGHEST=$(get_highest_from_specs "$SPECS_DIR") + BRANCH_NUMBER=$((HIGHEST + 1)) + fi fi - fi - FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") - BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))") + BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}" + fi fi # GitHub enforces a 244-byte limit on branch names MAX_BRANCH_LENGTH=244 -if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then +_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; } +BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME") +if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then + >&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes." + exit 1 +elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 )) MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH)) @@ -354,9 +377,6 @@ if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then >&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)" fi -FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" -SPEC_FILE="$FEATURE_DIR/spec.md" - if [ "$DRY_RUN" != true ]; then if [ "$HAS_GIT" = true ]; then branch_create_error="" @@ -394,22 +414,6 @@ if [ "$DRY_RUN" != true ]; then >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" fi - mkdir -p "$FEATURE_DIR" - - if [ ! -f "$SPEC_FILE" ]; then - if type resolve_template >/dev/null 2>&1; then - TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true - else - TEMPLATE="" - fi - if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then - cp "$TEMPLATE" "$SPEC_FILE" - else - echo "Warning: Spec template not found; created empty spec file" >&2 - touch "$SPEC_FILE" - fi - fi - printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 fi @@ -418,35 +422,30 @@ if $JSON_MODE; then if [ "$DRY_RUN" = true ]; then jq -cn \ --arg branch_name "$BRANCH_NAME" \ - --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}' else jq -cn \ --arg branch_name "$BRANCH_NAME" \ - --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + '{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}' fi else if type json_escape >/dev/null 2>&1; then _je_branch=$(json_escape "$BRANCH_NAME") - _je_spec=$(json_escape "$SPEC_FILE") _je_num=$(json_escape "$FEATURE_NUM") else _je_branch="$BRANCH_NAME" - _je_spec="$SPEC_FILE" _je_num="$FEATURE_NUM" fi if [ "$DRY_RUN" = true ]; then - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_spec" "$_je_num" + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num" else - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_spec" "$_je_num" + printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num" fi fi else echo "BRANCH_NAME: $BRANCH_NAME" - echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" if [ "$DRY_RUN" != true ]; then printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" diff --git a/extensions/git/scripts/powershell/create-new-feature.ps1 b/extensions/git/scripts/powershell/create-new-feature.ps1 index ded6eaa72f..b579f05160 100644 --- a/extensions/git/scripts/powershell/create-new-feature.ps1 +++ b/extensions/git/scripts/powershell/create-new-feature.ps1 @@ -23,12 +23,16 @@ if ($Help) { Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" - Write-Host " -DryRun Compute branch name and paths without creating branches, directories, or files" + Write-Host " -DryRun Compute branch name without creating the branch" Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing" Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" Write-Host " -Help Show this help message" + Write-Host "" + Write-Host "Environment variables:" + Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation" + Write-Host "" exit 0 } @@ -203,7 +207,9 @@ if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) { # Check if git is available if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { - $hasGit = Test-HasGit -RepoRoot $repoRoot + # Call without parameters for compatibility with core common.ps1 (no -RepoRoot param) + # and git-common.ps1 (has -RepoRoot param with default). + $hasGit = Test-HasGit } else { try { git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null @@ -216,9 +222,6 @@ if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) { Set-Location $repoRoot $specsDir = Join-Path $repoRoot 'specs' -if (-not $DryRun) { - New-Item -ItemType Directory -Path $specsDir -Force | Out-Null -} function Get-BranchName { param([string]$Description) @@ -255,35 +258,54 @@ function Get-BranchName { } } -if ($ShortName) { - $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName +# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix) +if ($env:GIT_BRANCH_NAME) { + $branchName = $env:GIT_BRANCH_NAME + # Check 244-byte limit (UTF-8) for override names + $branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName) + if ($branchNameUtf8ByteCount -gt 244) { + throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name." + } + # Extract FEATURE_NUM from the branch name if it starts with a numeric prefix + # Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern + if ($branchName -match '^(\d{8}-\d{6})-') { + $featureNum = $matches[1] + } elseif ($branchName -match '^(\d+)-') { + $featureNum = $matches[1] + } else { + $featureNum = $branchName + } } else { - $branchSuffix = Get-BranchName -Description $featureDesc -} + if ($ShortName) { + $branchSuffix = ConvertTo-CleanBranchName -Name $ShortName + } else { + $branchSuffix = Get-BranchName -Description $featureDesc + } -if ($Timestamp -and $Number -ne 0) { - Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" - $Number = 0 -} + if ($Timestamp -and $Number -ne 0) { + Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used" + $Number = 0 + } -if ($Timestamp) { - $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' - $branchName = "$featureNum-$branchSuffix" -} else { - if ($Number -eq 0) { - if ($DryRun -and $hasGit) { - $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch - } elseif ($DryRun) { - $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 - } elseif ($hasGit) { - $Number = Get-NextBranchNumber -SpecsDir $specsDir - } else { - $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + if ($Timestamp) { + $featureNum = Get-Date -Format 'yyyyMMdd-HHmmss' + $branchName = "$featureNum-$branchSuffix" + } else { + if ($Number -eq 0) { + if ($DryRun -and $hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch + } elseif ($DryRun) { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } elseif ($hasGit) { + $Number = Get-NextBranchNumber -SpecsDir $specsDir + } else { + $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1 + } } - } - $featureNum = ('{0:000}' -f $Number) - $branchName = "$featureNum-$branchSuffix" + $featureNum = ('{0:000}' -f $Number) + $branchName = "$featureNum-$branchSuffix" + } } $maxBranchLength = 244 @@ -302,9 +324,6 @@ if ($branchName.Length -gt $maxBranchLength) { Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)" } -$featureDir = Join-Path $specsDir $branchName -$specFile = Join-Path $featureDir 'spec.md' - if (-not $DryRun) { if ($hasGit) { $branchCreated = $false @@ -361,28 +380,12 @@ if (-not $DryRun) { } } - New-Item -ItemType Directory -Path $featureDir -Force | Out-Null - - if (-not (Test-Path -PathType Leaf $specFile)) { - if (Get-Command Resolve-Template -ErrorAction SilentlyContinue) { - $template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot - } else { - $template = $null - } - if ($template -and (Test-Path $template)) { - Copy-Item $template $specFile -Force - } else { - New-Item -ItemType File -Path $specFile -Force | Out-Null - } - } - $env:SPECIFY_FEATURE = $branchName } if ($Json) { $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName - SPEC_FILE = $specFile FEATURE_NUM = $featureNum HAS_GIT = $hasGit } @@ -392,7 +395,6 @@ if ($Json) { $obj | ConvertTo-Json -Compress } else { Write-Output "BRANCH_NAME: $branchName" - Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" if (-not $DryRun) { diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 5e45e8708c..04af7d794f 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -194,9 +194,35 @@ get_feature_paths() { has_git_repo="true" fi - # Use prefix-based lookup to support multiple branches per spec + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 3. Branch-name-based prefix lookup (legacy fallback) local feature_dir - if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then + feature_dir="$SPECIFY_FEATURE_DIRECTORY" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif [[ -f "$repo_root/.specify/feature.json" ]]; then + local _fd + if command -v jq >/dev/null 2>&1; then + _fd=$(jq -r '.feature_directory // empty' "$repo_root/.specify/feature.json" 2>/dev/null) + elif command -v python3 >/dev/null 2>&1; then + # Fallback: use Python to parse JSON so pretty-printed/multi-line files work + _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('feature_directory',''))" "$repo_root/.specify/feature.json" 2>/dev/null) + else + # Last resort: single-line grep fallback (won't work on multi-line JSON) + _fd=$(grep -o '"feature_directory"[[:space:]]*:[[:space:]]*"[^"]*"' "$repo_root/.specify/feature.json" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/') + fi + if [[ -n "$_fd" ]]; then + feature_dir="$_fd" + # Normalize relative paths to absolute under repo root + [[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir" + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then + echo "ERROR: Failed to resolve feature directory" >&2 + return 1 + fi + elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then echo "ERROR: Failed to resolve feature directory" >&2 return 1 fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 8c8c801ee3..35ed884f0f 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -160,7 +160,36 @@ function Get-FeaturePathsEnv { $repoRoot = Get-RepoRoot $currentBranch = Get-CurrentBranch $hasGit = Test-HasGit - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + + # Resolve feature directory. Priority: + # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) + # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) + # 3. Exact branch-to-directory mapping via Get-FeatureDir (legacy fallback) + $featureJson = Join-Path $repoRoot '.specify/feature.json' + if ($env:SPECIFY_FEATURE_DIRECTORY) { + $featureDir = $env:SPECIFY_FEATURE_DIRECTORY + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + } elseif (Test-Path $featureJson) { + try { + $featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json + if ($featureConfig.feature_directory) { + $featureDir = $featureConfig.feature_directory + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + } else { + $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + } + } catch { + $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + } + } else { + $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + } [PSCustomObject]@{ REPO_ROOT = $repoRoot diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 95ab2028c1..11b6e0eda5 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -35,7 +35,7 @@ import stat import yaml from pathlib import Path -from typing import Any, Optional, Tuple +from typing import Any, Optional import typer from rich.console import Console @@ -384,6 +384,7 @@ def check_tool(tool: str, tracker: StepTracker = None) -> bool: return found + def is_git_repo(path: Path = None) -> bool: """Check if the specified path is inside a git repository.""" if path is None: @@ -393,7 +394,6 @@ def is_git_repo(path: Path = None) -> bool: return False try: - # Use git command to check if inside a work tree subprocess.run( ["git", "rev-parse", "--is-inside-work-tree"], check=True, @@ -404,16 +404,9 @@ def is_git_repo(path: Path = None) -> bool: except (subprocess.CalledProcessError, FileNotFoundError): return False -def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Optional[str]]: - """Initialize a git repository in the specified path. - - Args: - project_path: Path to initialize git repository in - quiet: if True suppress console output (tracker handles status) - Returns: - Tuple of (success: bool, error_message: Optional[str]) - """ +def init_git_repo(project_path: Path, quiet: bool = False) -> tuple[bool, Optional[str]]: + """Initialize a git repository in the specified path.""" try: original_cwd = Path.cwd() os.chdir(project_path) @@ -425,20 +418,19 @@ def init_git_repo(project_path: Path, quiet: bool = False) -> Tuple[bool, Option if not quiet: console.print("[green]✓[/green] Git repository initialized") return True, None - except subprocess.CalledProcessError as e: error_msg = f"Command: {' '.join(e.cmd)}\nExit code: {e.returncode}" if e.stderr: error_msg += f"\nError: {e.stderr.strip()}" elif e.stdout: error_msg += f"\nOutput: {e.stdout.strip()}" - if not quiet: console.print(f"[red]Error initializing git repository:[/red] {e}") return False, error_msg finally: os.chdir(original_cwd) + def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker=None) -> None: """Handle merging or copying of .vscode/settings.json files. @@ -708,41 +700,45 @@ def _install_shared_infra( def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: - """Ensure POSIX .sh scripts under .specify/scripts (recursively) have execute bits (no-op on Windows).""" + """Ensure POSIX .sh scripts under .specify/scripts and .specify/extensions (recursively) have execute bits (no-op on Windows).""" if os.name == "nt": return # Windows: skip silently - scripts_root = project_path / ".specify" / "scripts" - if not scripts_root.is_dir(): - return + scan_roots = [ + project_path / ".specify" / "scripts", + project_path / ".specify" / "extensions", + ] failures: list[str] = [] updated = 0 - for script in scripts_root.rglob("*.sh"): - try: - if script.is_symlink() or not script.is_file(): - continue + for scripts_root in scan_roots: + if not scripts_root.is_dir(): + continue + for script in scripts_root.rglob("*.sh"): try: - with script.open("rb") as f: - if f.read(2) != b"#!": - continue - except Exception: - continue - st = script.stat() - mode = st.st_mode - if mode & 0o111: - continue - new_mode = mode - if mode & 0o400: - new_mode |= 0o100 - if mode & 0o040: - new_mode |= 0o010 - if mode & 0o004: - new_mode |= 0o001 - if not (new_mode & 0o100): - new_mode |= 0o100 - os.chmod(script, new_mode) - updated += 1 - except Exception as e: - failures.append(f"{script.relative_to(scripts_root)}: {e}") + if script.is_symlink() or not script.is_file(): + continue + try: + with script.open("rb") as f: + if f.read(2) != b"#!": + continue + except Exception: + continue + st = script.stat() + mode = st.st_mode + if mode & 0o111: + continue + new_mode = mode + if mode & 0o400: + new_mode |= 0o100 + if mode & 0o040: + new_mode |= 0o010 + if mode & 0o004: + new_mode |= 0o001 + if not (new_mode & 0o100): + new_mode |= 0o100 + os.chmod(script, new_mode) + updated += 1 + except Exception as e: + failures.append(f"{script.relative_to(project_path)}: {e}") if tracker: detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") tracker.add("chmod", "Set script permissions recursively") @@ -993,9 +989,11 @@ def init( console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}") raise typer.Exit(1) + dir_existed_before = False if here: project_name = Path.cwd().name project_path = Path.cwd() + dir_existed_before = True existing_items = list(project_path.iterdir()) if existing_items: @@ -1010,17 +1008,29 @@ def init( raise typer.Exit(0) else: project_path = Path(project_name).resolve() + dir_existed_before = project_path.exists() if project_path.exists(): - error_panel = Panel( - f"Directory '[cyan]{project_name}[/cyan]' already exists\n" - "Please choose a different project name or remove the existing directory.", - title="[red]Directory Conflict[/red]", - border_style="red", - padding=(1, 2) - ) - console.print() - console.print(error_panel) - raise typer.Exit(1) + if not project_path.is_dir(): + console.print(f"[red]Error:[/red] '{project_name}' exists but is not a directory.") + raise typer.Exit(1) + existing_items = list(project_path.iterdir()) + if force: + if existing_items: + console.print(f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)") + console.print("[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]") + console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]") + else: + error_panel = Panel( + f"Directory '[cyan]{project_name}[/cyan]' already exists\n" + "Please choose a different project name or remove the existing directory.\n" + "Use [bold]--force[/bold] to merge into the existing directory.", + title="[red]Directory Conflict[/red]", + border_style="red", + padding=(1, 2) + ) + console.print() + console.print(error_panel) + raise typer.Exit(1) if ai_assistant: if ai_assistant not in AGENT_CONFIG: @@ -1123,14 +1133,11 @@ def init( for key, label in [ ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), - ("git", "Initialize git repository"), + ("git", "Install git extension"), ("final", "Finalize"), ]: tracker.add(key, label) - # Track git error message outside Live context so it persists - git_error_message = None - with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: @@ -1177,26 +1184,62 @@ def init( _install_shared_infra(project_path, selected_script, tracker=tracker) tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") - ensure_executable_scripts(project_path, tracker=tracker) - ensure_constitution_from_template(project_path, tracker=tracker) if not no_git: tracker.start("git") + git_messages = [] + git_has_error = False + # Step 1: Initialize git repo if needed if is_git_repo(project_path): - tracker.complete("git", "existing repo detected") + git_messages.append("existing repo detected") elif should_init_git: success, error_msg = init_git_repo(project_path, quiet=True) if success: - tracker.complete("git", "initialized") + git_messages.append("initialized") + else: + git_has_error = True + # Sanitize multi-line error_msg to single line for tracker + if error_msg: + sanitized = error_msg.replace('\n', ' ').strip() + git_messages.append(f"init failed: {sanitized[:120]}") + else: + git_messages.append("init failed") + else: + git_messages.append("git not available") + # Step 2: Install bundled git extension + try: + from .extensions import ExtensionManager + bundled_path = _locate_bundled_extension("git") + if bundled_path: + manager = ExtensionManager(project_path) + if manager.registry.is_installed("git"): + git_messages.append("extension already installed") + else: + manager.install_from_directory( + bundled_path, get_speckit_version() + ) + git_messages.append("extension installed") else: - tracker.error("git", "init failed") - git_error_message = error_msg + git_has_error = True + git_messages.append("bundled extension not found") + except Exception as ext_err: + git_has_error = True + sanitized_ext = str(ext_err).replace('\n', ' ').strip() + git_messages.append( + f"extension install failed: {sanitized_ext[:120]}" + ) + summary = "; ".join(git_messages) + if git_has_error: + tracker.error("git", summary) else: - tracker.skip("git", "git not available") + tracker.complete("git", summary) else: tracker.skip("git", "--no-git flag") + # Fix permissions after all installs (scripts + extensions) + ensure_executable_scripts(project_path, tracker=tracker) + # Persist the CLI options so later operations (e.g. preset add) # can adapt their behaviour without re-scanning the filesystem. # Must be saved BEFORE preset install so _get_skills_dir() works. @@ -1262,7 +1305,7 @@ def init( _label_width = max(len(k) for k, _ in _env_pairs) env_lines = [f"{k.ljust(_label_width)} → [bright_black]{v}[/bright_black]" for k, v in _env_pairs] console.print(Panel("\n".join(env_lines), title="Debug Environment", border_style="magenta")) - if not here and project_path.exists(): + if not here and project_path.exists() and not dir_existed_before: shutil.rmtree(project_path) raise typer.Exit(1) finally: @@ -1271,23 +1314,6 @@ def init( console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") - # Show git error details if initialization failed - if git_error_message: - console.print() - git_error_panel = Panel( - f"[yellow]Warning:[/yellow] Git repository initialization failed\n\n" - f"{git_error_message}\n\n" - f"[dim]You can initialize git manually later with:[/dim]\n" - f"[cyan]cd {project_path if not here else '.'}[/cyan]\n" - f"[cyan]git init[/cyan]\n" - f"[cyan]git add .[/cyan]\n" - f"[cyan]git commit -m \"Initial commit\"[/cyan]", - title="[red]Git Initialization Failed[/red]", - border_style="red", - padding=(1, 2) - ) - console.print(git_error_panel) - # Agent folder security notice agent_config = AGENT_CONFIG.get(selected_ai) if agent_config: diff --git a/templates/commands/specify.md b/templates/commands/specify.md index a81b8f12f1..15c75ec396 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -8,9 +8,6 @@ handoffs: agent: speckit.clarify prompt: Clarify specification requirements send: true -scripts: - sh: scripts/bash/create-new-feature.sh "{ARGS}" - ps: scripts/powershell/create-new-feature.ps1 "{ARGS}" --- ## User Input @@ -61,7 +58,7 @@ The text the user typed after `/speckit.specify` in the triggering message **is* Given that feature description, do this: -1. **Generate a concise short name** (2-4 words) for the branch: +1. **Generate a concise short name** (2-4 words) for the feature: - Analyze the feature description and extract the most meaningful keywords - Create a 2-4 word short name that captures the essence of the feature - Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug") @@ -73,30 +70,47 @@ Given that feature description, do this: - "Create a dashboard for analytics" → "analytics-dashboard" - "Fix payment processing timeout bug" → "fix-payment-timeout" -2. **Create the feature branch** by running the script with `--short-name` (and `--json`). In sequential mode, do NOT pass `--number` — the script auto-detects the next available number. In timestamp mode, the script generates a `YYYYMMDD-HHMMSS` prefix automatically: +2. **Branch creation** (optional, via hook): - **Branch numbering mode**: Before running the script, check if `.specify/init-options.json` exists and read the `branch_numbering` value. - - If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation - - If `"sequential"` or absent, do not add any extra flag (default behavior) + If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name. - - Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"` - - Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"` - - PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"` - - PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"` + If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation). - **IMPORTANT**: - - Do NOT pass `--number` — the script determines the correct next number automatically - - Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably - - You must only ever run this script once per feature - - The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for - - The JSON output will contain BRANCH_NAME and SPEC_FILE paths - - For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot") +3. **Create the spec feature directory**: + + Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`. + + **Resolution order for `SPECIFY_FEATURE_DIRECTORY`**: + 1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is + 2. Otherwise, auto-generate it under `specs/`: + - Check `.specify/init-options.json` for `branch_numbering` + - If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp) + - If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`) + - Construct the directory name: `-` (e.g., `003-user-auth` or `20260319-143022-user-auth`) + - Set `SPECIFY_FEATURE_DIRECTORY` to `specs/` -3. Load `templates/spec-template.md` to understand required sections. + **Create the directory and spec file**: + - `mkdir -p SPECIFY_FEATURE_DIRECTORY` + - Copy `templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point + - Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md` + - Persist the resolved path to `.specify/feature.json`: + ```json + { + "feature_directory": "" + } + ``` + Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`. + This allows downstream commands (`/speckit.plan`, `/speckit.tasks`, etc.) to locate the feature directory without relying on git branch name conventions. + + **IMPORTANT**: + - You must only create one feature per `/speckit.specify` invocation + - 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 -4. Follow this execution flow: +4. Load `templates/spec-template.md` to understand required sections. - 1. Parse user description from Input +5. Follow this execution flow: + 1. Parse user description from arguments If empty: ERROR "No feature description provided" 2. Extract key concepts from description Identify: actors, actions, data, constraints @@ -120,11 +134,11 @@ Given that feature description, do this: 7. Identify Key Entities (if data involved) 8. Return: SUCCESS (spec ready for planning) -5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. +6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings. -6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: +7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria: - a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items: + a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items: ```markdown # Specification Quality Checklist: [FEATURE NAME] @@ -214,9 +228,13 @@ Given that feature description, do this: d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status -7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`). +8. **Report completion** to the user with: + - `SPECIFY_FEATURE_DIRECTORY` — the feature directory path + - `SPEC_FILE` — the spec file path + - Checklist results summary + - Readiness for the next phase (`/speckit.clarify` or `/speckit.plan`) -8. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. +9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_specify` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. @@ -245,7 +263,7 @@ Given that feature description, do this: ``` - If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently -**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing. +**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command. ## Quick Guidelines diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 721bd999f2..098caf53b7 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -280,7 +280,6 @@ def test_creates_branch_sequential(self, tmp_path: Path): assert result.returncode == 0, result.stderr data = json.loads(result.stdout) assert data["BRANCH_NAME"] == "001-user-auth" - assert "SPEC_FILE" in data assert data["FEATURE_NUM"] == "001" def test_creates_branch_timestamp(self, tmp_path: Path): @@ -294,18 +293,6 @@ def test_creates_branch_timestamp(self, tmp_path: Path): data = json.loads(result.stdout) assert re.match(r"^\d{8}-\d{6}-feat$", data["BRANCH_NAME"]) - def test_creates_spec_dir(self, tmp_path: Path): - """create-new-feature.sh creates specs directory and spec.md.""" - project = _setup_project(tmp_path) - result = _run_bash( - "create-new-feature.sh", project, - "--json", "--short-name", "test-feat", "Test feature", - ) - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout) - spec_file = Path(data["SPEC_FILE"]) - assert spec_file.exists(), f"spec.md not created at {spec_file}" - def test_increments_from_existing_specs(self, tmp_path: Path): """Sequential numbering increments past existing spec directories.""" project = _setup_project(tmp_path) @@ -321,7 +308,7 @@ def test_increments_from_existing_specs(self, tmp_path: Path): assert data["FEATURE_NUM"] == "003" def test_no_git_graceful_degradation(self, tmp_path: Path): - """create-new-feature.sh works without git (creates spec dir only).""" + """create-new-feature.sh works without git (outputs branch name, skips branch creation).""" project = _setup_project(tmp_path, git=False) result = _run_bash( "create-new-feature.sh", project, @@ -330,8 +317,8 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): assert result.returncode == 0, result.stderr assert "Warning" in result.stderr data = json.loads(result.stdout) - spec_file = Path(data["SPEC_FILE"]) - assert spec_file.exists() + assert "BRANCH_NAME" in data + assert "FEATURE_NUM" in data def test_dry_run(self, tmp_path: Path): """--dry-run computes branch name without creating anything.""" @@ -382,7 +369,8 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")] assert json_line, f"No JSON in output: {result.stdout}" data = json.loads(json_line[-1]) - assert Path(data["SPEC_FILE"]).exists() + assert "BRANCH_NAME" in data + assert "FEATURE_NUM" in data # ── auto-commit.sh Tests ───────────────────────────────────────────────────── diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 945ce6ac62..1e23e35a7d 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -3,6 +3,8 @@ import json import os +import yaml + class TestInitIntegrationFlag: def test_integration_and_ai_mutually_exclusive(self, tmp_path): @@ -147,3 +149,142 @@ def test_shared_infra_skips_existing_files(self, tmp_path): # Other shared files should still be installed assert (scripts_dir / "setup-plan.sh").exists() assert (templates_dir / "plan-template.md").exists() + + +class TestForceExistingDirectory: + """Tests for --force merging into an existing named directory.""" + + def test_force_merges_into_existing_dir(self, tmp_path): + """specify init --force succeeds when the directory already exists.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "existing-proj" + target.mkdir() + # Place a pre-existing file to verify it survives the merge + marker = target / "user-file.txt" + marker.write_text("keep me", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(target), "--integration", "copilot", "--force", + "--no-git", "--script", "sh", + ], catch_exceptions=False) + + assert result.exit_code == 0, f"init --force failed: {result.output}" + + # Pre-existing file should survive + assert marker.read_text(encoding="utf-8") == "keep me" + + # Spec Kit files should be installed + assert (target / ".specify" / "init-options.json").exists() + assert (target / ".specify" / "templates" / "spec-template.md").exists() + + def test_without_force_errors_on_existing_dir(self, tmp_path): + """specify init without --force errors when directory exists.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "existing-proj" + target.mkdir() + + runner = CliRunner() + result = runner.invoke(app, [ + "init", str(target), "--integration", "copilot", + "--no-git", "--script", "sh", + ], catch_exceptions=False) + + assert result.exit_code == 1 + assert "already exists" in result.output + + +class TestGitExtensionAutoInstall: + """Tests for auto-installation of the git extension during specify init.""" + + def test_git_extension_auto_installed(self, tmp_path): + """Without --no-git, the git extension is installed during init.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "git-auto" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + # Check that the tracker didn't report a git error + assert "install failed" not in result.output, f"git extension install failed: {result.output}" + + # Git extension files should be installed + ext_dir = project / ".specify" / "extensions" / "git" + assert ext_dir.exists(), "git extension directory not installed" + assert (ext_dir / "extension.yml").exists() + assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists() + assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists() + + # Hooks should be registered + extensions_yml = project / ".specify" / "extensions.yml" + assert extensions_yml.exists(), "extensions.yml not created" + hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8")) + assert "hooks" in hooks_data + assert "before_specify" in hooks_data["hooks"] + assert "before_constitution" in hooks_data["hooks"] + + def test_no_git_skips_extension(self, tmp_path): + """With --no-git, the git extension is NOT installed.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "no-git" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + # Git extension should NOT be installed + ext_dir = project / ".specify" / "extensions" / "git" + assert not ext_dir.exists(), "git extension should not be installed with --no-git" + + def test_git_extension_commands_registered(self, tmp_path): + """Git extension commands are registered with the agent during init.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "git-cmds" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + + # Git extension commands should be registered with the agent + claude_skills = project / ".claude" / "skills" + assert claude_skills.exists(), "Claude skills directory was not created" + git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")] + assert len(git_skills) > 0, "no git extension commands registered" diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 605ae48965..2161d2893c 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -4,6 +4,7 @@ Converted from tests/test_timestamp_branches.sh so they are discovered by `uv run pytest`. """ +import json import os import re import shutil @@ -22,6 +23,8 @@ PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" ) COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" +EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" @pytest.fixture @@ -47,6 +50,62 @@ def git_repo(tmp_path: Path) -> Path: return tmp_path +@pytest.fixture +def ext_git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with extension scripts (for GIT_BRANCH_NAME tests).""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True) + # Extension script needs common.sh at .specify/scripts/bash/ + specify_scripts = tmp_path / ".specify" / "scripts" / "bash" + specify_scripts.mkdir(parents=True) + shutil.copy(COMMON_SH, specify_scripts / "common.sh") + # Also install core scripts for compatibility + core_scripts = tmp_path / "scripts" / "bash" + core_scripts.mkdir(parents=True) + shutil.copy(COMMON_SH, core_scripts / "common.sh") + # Copy extension script + ext_dir = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "bash" + ext_dir.mkdir(parents=True) + shutil.copy(EXT_CREATE_FEATURE, ext_dir / "create-new-feature.sh") + # Also copy git-common.sh if it exists + git_common = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + if git_common.exists(): + shutil.copy(git_common, ext_dir / "git-common.sh") + (tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True) + (tmp_path / "specs").mkdir(exist_ok=True) + return tmp_path + + +@pytest.fixture +def ext_ps_git_repo(tmp_path: Path) -> Path: + """Create a temp git repo with PowerShell extension scripts.""" + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=tmp_path, check=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=tmp_path, check=True) + # Install core PS scripts + ps_dir = tmp_path / "scripts" / "powershell" + ps_dir.mkdir(parents=True) + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + shutil.copy(common_ps, ps_dir / "common.ps1") + # Also install at .specify/scripts/powershell/ for extension resolution + specify_ps = tmp_path / ".specify" / "scripts" / "powershell" + specify_ps.mkdir(parents=True) + shutil.copy(common_ps, specify_ps / "common.ps1") + # Copy extension script + ext_ps = tmp_path / ".specify" / "extensions" / "git" / "scripts" / "powershell" + ext_ps.mkdir(parents=True) + shutil.copy(EXT_CREATE_FEATURE_PS, ext_ps / "create-new-feature.ps1") + git_common_ps = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + if git_common_ps.exists(): + shutil.copy(git_common_ps, ext_ps / "git-common.ps1") + (tmp_path / ".specify" / "templates").mkdir(parents=True, exist_ok=True) + (tmp_path / "specs").mkdir(exist_ok=True) + return tmp_path + + @pytest.fixture def no_git_dir(tmp_path: Path) -> Path: """Create a temp directory without git, but with scripts.""" @@ -837,3 +896,262 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): assert result.returncode == 0, result.stderr data = json.loads(result.stdout) assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}" + + +# ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── + + +class TestGitBranchNameOverrideBash: + """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" + + def _run_ext(self, ext_git_repo: Path, env_extras: dict, *extra_args: str): + script = ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" + cmd = ["bash", str(script), "--json", *extra_args, "ignored"] + return subprocess.run(cmd, cwd=ext_git_repo, capture_output=True, text=True, + env={**os.environ, **env_extras}) + + def test_exact_name_no_prefix(self, ext_git_repo: Path): + """GIT_BRANCH_NAME is used verbatim with no numeric prefix added.""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "my-exact-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "my-exact-branch" + assert data["FEATURE_NUM"] == "my-exact-branch" + + def test_sequential_prefix_extraction(self, ext_git_repo: Path): + """FEATURE_NUM extracted from sequential-style prefix (digits before dash).""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "042-custom-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "042-custom-branch" + assert data["FEATURE_NUM"] == "042" + + def test_timestamp_prefix_extraction(self, ext_git_repo: Path): + """FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names.""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-my-feature"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "20260407-143022-my-feature" + assert data["FEATURE_NUM"] == "20260407-143022" + + def test_overlong_name_rejected(self, ext_git_repo: Path): + """GIT_BRANCH_NAME exceeding 244 bytes is rejected with an error.""" + long_name = "a" * 245 + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": long_name}) + assert result.returncode != 0 + assert "244" in result.stderr + + def test_dry_run_with_override(self, ext_git_repo: Path): + """GIT_BRANCH_NAME works with --dry-run (no branch created).""" + result = self._run_ext(ext_git_repo, {"GIT_BRANCH_NAME": "dry-run-override"}, "--dry-run") + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "dry-run-override" + assert data.get("DRY_RUN") is True + branches = subprocess.run( + ["git", "branch", "--list", "dry-run-override"], + cwd=ext_git_repo, capture_output=True, text=True, + ) + assert "dry-run-override" not in branches.stdout + + +@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") +class TestGitBranchNameOverridePowerShell: + """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.ps1.""" + + def _run_ext(self, ext_ps_git_repo: Path, env_extras: dict): + script = ext_ps_git_repo / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" + return subprocess.run( + ["pwsh", "-NoProfile", "-File", str(script), "-Json", "ignored"], + cwd=ext_ps_git_repo, capture_output=True, text=True, + env={**os.environ, **env_extras}, + ) + + def test_exact_name_no_prefix(self, ext_ps_git_repo: Path): + """GIT_BRANCH_NAME is used verbatim with no numeric prefix added.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "ps-exact-branch"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "ps-exact-branch" + assert data["FEATURE_NUM"] == "ps-exact-branch" + + def test_sequential_prefix_extraction(self, ext_ps_git_repo: Path): + """FEATURE_NUM extracted from sequential-style prefix.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "099-ps-numbered"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "099-ps-numbered" + assert data["FEATURE_NUM"] == "099" + + def test_timestamp_prefix_extraction(self, ext_ps_git_repo: Path): + """FEATURE_NUM extracted as full YYYYMMDD-HHMMSS for timestamp-style names.""" + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": "20260407-143022-ps-feature"}) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["BRANCH_NAME"] == "20260407-143022-ps-feature" + assert data["FEATURE_NUM"] == "20260407-143022" + + def test_overlong_name_rejected(self, ext_ps_git_repo: Path): + """GIT_BRANCH_NAME exceeding 244 bytes is rejected.""" + long_name = "a" * 245 + result = self._run_ext(ext_ps_git_repo, {"GIT_BRANCH_NAME": long_name}) + assert result.returncode != 0 + assert "244" in result.stderr + + +# ── Feature Directory Resolution Tests ─────────────────────────────────────── + + +class TestFeatureDirectoryResolution: + """Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution.""" + + def test_env_var_overrides_branch_lookup(self, git_repo: Path): + """SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup.""" + custom_dir = git_repo / "my-custom-specs" / "my-feature" + custom_dir.mkdir(parents=True) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)}, + ) + assert result.returncode == 0, result.stderr + assert str(custom_dir) in result.stdout + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + def test_feature_json_overrides_branch_lookup(self, git_repo: Path): + """feature.json feature_directory takes priority over branch-based lookup.""" + custom_dir = git_repo / "specs" / "custom-feature" + custom_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + f'{{"feature_directory": "{custom_dir}"}}\n', + encoding="utf-8", + ) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): + """Env var wins over feature.json.""" + env_dir = git_repo / "specs" / "env-feature" + env_dir.mkdir(parents=True) + json_dir = git_repo / "specs" / "json-feature" + json_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + f'{{"feature_directory": "{json_dir}"}}\n', + encoding="utf-8", + ) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(env_dir)}, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(env_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + def test_fallback_to_branch_lookup(self, git_repo: Path): + """Without env var or feature.json, falls back to branch-based lookup.""" + subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True) + spec_dir = git_repo / "specs" / "001-test-feat" + spec_dir.mkdir(parents=True) + + result = subprocess.run( + ["bash", "-c", f'source "{COMMON_SH}" && get_feature_paths'], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(spec_dir) + break + else: + pytest.fail("FEATURE_DIR not found in output") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_env_var_overrides_branch_lookup(self, git_repo: Path): + """PowerShell: SPECIFY_FEATURE_DIRECTORY env var takes priority.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + custom_dir = git_repo / "my-custom-specs" / "ps-feature" + custom_dir.mkdir(parents=True) + + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE_DIRECTORY": str(custom_dir)}, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path): + """PowerShell: feature.json takes priority over branch-based lookup.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + custom_dir = git_repo / "specs" / "ps-json-feature" + custom_dir.mkdir(parents=True) + + feature_json = git_repo / ".specify" / "feature.json" + feature_json.write_text( + f'{{"feature_directory": "{custom_dir}"}}\n', + encoding="utf-8", + ) + + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip("'\"") + assert val == str(custom_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") From 8472e442151e32e1ec5dbd3a0f3ee41fe6d68f68 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 9 Apr 2026 00:01:47 +0500 Subject: [PATCH 008/184] Add Spec Diagram community extension to catalog and README (#2129) Adds the spec-kit-diagram extension (3 commands, 1 hook) that auto-generates Mermaid diagrams for SDD workflow visualization, feature progress tracking, and task dependency graphs. Addresses community request in issue #467 (50+ upvotes). Co-authored-by: Claude Sonnet 4.6 --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index cb3891d98a..fd7b3ce960 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ The following community-contributed extensions are available in [`catalog.commun | Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | +| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ee944cde59..8d91c56744 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -322,6 +322,38 @@ "created_at": "2026-03-29T00:00:00Z", "updated_at": "2026-03-29T00:00:00Z" }, + "diagram": { + "name": "Spec Diagram", + "id": "diagram", + "description": "Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-diagram-/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-diagram-", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-diagram-", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-diagram-/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-diagram-/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "diagram", + "mermaid", + "visualization", + "workflow", + "dependencies" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-08T00:00:00Z" + }, "docguard": { "name": "DocGuard — CDD Enforcement", "id": "docguard", From 9c73e68528e4f296d7ac149272c9fc08c1216768 Mon Sep 17 00:00:00 2001 From: 404prefrontalcortexnotfound <106208474+404prefrontalcortexnotfound@users.noreply.github.com> Date: Thu, 9 Apr 2026 05:29:50 +1000 Subject: [PATCH 009/184] fix(bash): sed replacement escaping, BSD portability, dead cleanup in update-agent-context.sh (#2090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bash): sed replacement escaping, BSD portability, dead cleanup code Three bugs in update-agent-context.sh: 1. **sed escaping targets wrong side** (line 318-320): The escaping function escapes regex pattern characters (`[`, `.`, `*`, `^`, `$`, `+`, `{`, `}`, `|`) but these variables are used as sed *replacement* strings, not patterns. Only `&` (insert matched text), `\` (escape char), and `|` (our sed delimiter) are special in the replacement context. Also adds escaping for `project_name` which was used unescaped. 2. **BSD sed newline insertion fails on macOS** (line 364-366): Uses bash variable expansion to insert a literal newline into a sed replacement string. This works on GNU sed (Linux) but fails silently on BSD sed (macOS). Replaced with portable awk approach that works on both platforms. 3. **cleanup() removes non-existent files** (line 125-126): The cleanup trap attempts `rm -f /tmp/agent_update_*_$$` and `rm -f /tmp/manual_additions_$$` but the script never creates files matching these patterns — all temp files use `mktemp`. The wildcard with `$$` (PID) in /tmp could theoretically match unrelated files. Fixes #154 (macOS sed failure) Fixes #293 (sed expression errors) Related: #338 (shellcheck findings) * fix: restore forge case and revert copilot path change Address PR review feedback: - Restore forge) case in update_specific_agent since src/specify_cli/integrations/forge/__init__.py still exists - Revert COPILOT_FILE path from .github/agents/ back to .github/ to stay consistent with Python integration and tests - Restore FORGE_FILE variable, comments, and usage strings Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract repeated sed escaping into _esc_sed helper Address Gemini review feedback — the inline sed escaping pattern appeared 7 times in create_new_agent_file(). Extract to a single helper function for maintainability and readability. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: restore combined AGENTS_FILE label in update_all_existing_agents Gemini correctly identified that splitting AGENTS_FILE updates into individual calls is redundant — _update_if_new deduplicates by realpath, so only the first call logs. Restore the combined label and add back missing Pi reference. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: remove pre-escaped && in JS/TS commands now that _esc_sed handles it The old code manually pre-escaped & as \& in get_commands_for_language because the broken escaping function didn't handle &. Now that _esc_sed properly escapes replacement-side specials, the pre-escaping causes double-escaping: && becomes \&\& in generated files. Found by blind audit. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: split awk && mv to let set -e catch awk failures Under set -e, the left side of && does not trigger errexit on failure. Split into two statements so awk failures are fatal instead of silent. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: guard empty _CLEANUP_FILES array for Bash 3.2 compatibility On Bash 3.2, the ${arr[@]+"${arr[@]}"} pattern expands to a single empty string when the array is empty, causing rm to target .bak and .tmp in the current directory. Use explicit length check instead, which also avoids the word-splitting risk of unquoted expansion. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Bo Bobson Co-authored-by: Claude Opus 4.6 (1M context) --- scripts/bash/update-agent-context.sh | 48 ++++++++++++++++++---------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index b0ef4b422a..fce379b34d 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -117,13 +117,19 @@ log_warning() { echo "WARNING: $1" >&2 } +# Track temporary files for cleanup on interrupt +_CLEANUP_FILES=() + # Cleanup function for temporary files cleanup() { local exit_code=$? # Disarm traps to prevent re-entrant loop trap - EXIT INT TERM - rm -f /tmp/agent_update_*_$$ - rm -f /tmp/manual_additions_$$ + if [ ${#_CLEANUP_FILES[@]} -gt 0 ]; then + for f in "${_CLEANUP_FILES[@]}"; do + rm -f "$f" "$f.bak" "$f.tmp" + done + fi exit $exit_code } @@ -268,7 +274,7 @@ get_commands_for_language() { echo "cargo test && cargo clippy" ;; *"JavaScript"*|*"TypeScript"*) - echo "npm test \\&\\& npm run lint" + echo "npm test && npm run lint" ;; *) echo "# Add commands for $lang" @@ -281,10 +287,15 @@ get_language_conventions() { echo "$lang: Follow standard conventions" } +# Escape sed replacement-side specials for | delimiter. +# & and \ are replacement-side specials; | is our sed delimiter. +_esc_sed() { printf '%s\n' "$1" | sed 's/[\\&|]/\\&/g'; } + create_new_agent_file() { local target_file="$1" local temp_file="$2" - local project_name="$3" + local project_name + project_name=$(_esc_sed "$3") local current_date="$4" if [[ ! -f "$TEMPLATE_FILE" ]]; then @@ -307,18 +318,19 @@ create_new_agent_file() { # Replace template placeholders local project_structure project_structure=$(get_project_structure "$NEW_PROJECT_TYPE") + project_structure=$(_esc_sed "$project_structure") local commands commands=$(get_commands_for_language "$NEW_LANG") - + local language_conventions language_conventions=$(get_language_conventions "$NEW_LANG") - - # Perform substitutions with error checking using safer approach - # Escape special characters for sed by using a different delimiter or escaping - local escaped_lang=$(printf '%s\n' "$NEW_LANG" | sed 's/[\[\.*^$()+{}|]/\\&/g') - local escaped_framework=$(printf '%s\n' "$NEW_FRAMEWORK" | sed 's/[\[\.*^$()+{}|]/\\&/g') - local escaped_branch=$(printf '%s\n' "$CURRENT_BRANCH" | sed 's/[\[\.*^$()+{}|]/\\&/g') + + local escaped_lang=$(_esc_sed "$NEW_LANG") + local escaped_framework=$(_esc_sed "$NEW_FRAMEWORK") + commands=$(_esc_sed "$commands") + language_conventions=$(_esc_sed "$language_conventions") + local escaped_branch=$(_esc_sed "$CURRENT_BRANCH") # Build technology stack and recent change strings conditionally local tech_stack @@ -361,17 +373,18 @@ create_new_agent_file() { fi done - # Convert \n sequences to actual newlines - newline=$(printf '\n') - sed -i.bak2 "s/\\\\n/${newline}/g" "$temp_file" + # Convert literal \n sequences to actual newlines (portable — works on BSD + GNU) + awk '{gsub(/\\n/,"\n")}1' "$temp_file" > "$temp_file.tmp" + mv "$temp_file.tmp" "$temp_file" - # Clean up backup files - rm -f "$temp_file.bak" "$temp_file.bak2" + # Clean up backup files from sed -i.bak + rm -f "$temp_file.bak" # Prepend Cursor frontmatter for .mdc files so rules are auto-included if [[ "$target_file" == *.mdc ]]; then local frontmatter_file frontmatter_file=$(mktemp) || return 1 + _CLEANUP_FILES+=("$frontmatter_file") printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" cat "$temp_file" >> "$frontmatter_file" mv "$frontmatter_file" "$temp_file" @@ -395,6 +408,7 @@ update_existing_agent_file() { log_error "Failed to create temporary file" return 1 } + _CLEANUP_FILES+=("$temp_file") # Process the file in one pass local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK") @@ -519,6 +533,7 @@ update_existing_agent_file() { if ! head -1 "$temp_file" | grep -q '^---'; then local frontmatter_file frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; } + _CLEANUP_FILES+=("$frontmatter_file") printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" cat "$temp_file" >> "$frontmatter_file" mv "$frontmatter_file" "$temp_file" @@ -571,6 +586,7 @@ update_agent_file() { log_error "Failed to create temporary file" return 1 } + _CLEANUP_FILES+=("$temp_file") if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then if mv "$temp_file" "$target_file"; then From 71143598bec2bc44e8696fad6a3e6c5b2ec9a961 Mon Sep 17 00:00:00 2001 From: toxicafunk Date: Wed, 8 Apr 2026 21:37:19 +0200 Subject: [PATCH 010/184] fix(forge): use hyphen notation in frontmatter name field (#2075) * fix(forge): use hyphen notation in frontmatter name field - Changed injected name field from 'speckit.{command}' to 'speckit-{command}' - Keeps standard filename format 'speckit.{command}.md' - Aligns with Forge's command naming convention requirements - All tests pass * feat(forge): centralize name formatting to fix extension/preset command names Address PR feedback by centralizing Forge command name formatting to ensure consistent hyphenated names across both core template setup and extension/preset command registration. Changes: - Add format_forge_command_name() utility function in forge integration - Update ForgeIntegration._apply_forge_transformations() to use centralized formatter - Add _format_name_for_agent() helper in CommandRegistrar to apply agent-specific formatting - Update CommandRegistrar.register_commands() to format names for Forge (both primary commands and aliases) - Add comprehensive test coverage for the formatter and registrar behavior Impact: - Extension commands installed for Forge now use 'name: speckit-my-extension-example' instead of 'name: speckit.my-extension.example' - Fixes ZSH/shell compatibility issues with dot notation in command names - Maintains backward compatibility for all other agents (they continue using dot notation) - Eliminates duplication between integration setup and registrar paths Example transformation: Before: name: speckit.jira.sync-status (breaks in ZSH/Forge) After: name: speckit-jira-sync-status (works everywhere) Fixes inconsistency where core templates used hyphens but extension/preset commands preserved dots, breaking Forge's naming requirements. * refactor(forge): move name formatting logic to integration module Move _format_name_for_agent function logic into Forge integration's registrar_config as a 'format_name' callback, improving separation of concerns and keeping Forge-specific logic within its integration module. Changes: - Remove _format_name_for_agent() from agents.py (shared module) - Add 'format_name' callback to Forge's registrar_config pointing to format_forge_command_name - Update CommandRegistrar to use format_name callback when available - Maintains same behavior: Forge commands use hyphenated names, others use dot notation Benefits: - Better encapsulation: Forge-specific logic lives in forge integration - More extensible: Other integrations can provide custom formatters via registrar_config - Cleaner separation: agents.py doesn't need to know about specific agent requirements * fix(forge): make format_forge_command_name idempotent Handle already-hyphenated names (speckit-foo) to prevent double-prefixing (speckit-speckit-foo). The function now returns already-formatted names unchanged, making it safe to call multiple times. Changes: - Add early return for names starting with 'speckit-' - Update docstring to clarify accepted input formats - Add examples showing idempotent behavior - Add test coverage for idempotent behavior Examples: format_forge_command_name('speckit-plan') -> 'speckit-plan' (unchanged) format_forge_command_name('speckit.plan') -> 'speckit-plan' (converted) format_forge_command_name('plan') -> 'speckit-plan' (prefixed) * test(forge): strengthen name field assertions and clarify comments Improve test_name_field_uses_hyphenated_format to fail loudly when the name field is missing instead of silently passing. Changes: - Add explicit assertion that name_match is not None before validating value - Ensures test fails if regex doesn't match (e.g., frontmatter rendering changes) - Clarify Claude comment: it doesn't use inject_name path but SKILL.md frontmatter still includes hyphenated name via build_skill_frontmatter() Before: Test would silently pass if 'name:' field was missing from frontmatter After: Test explicitly asserts field presence before validating format * docs(forge): clarify frontmatter name requirement and improve test isolation Fix misleading docstring and improve test to properly validate that the format_name callback is Forge-specific. Changes to src/specify_cli/integrations/forge/__init__.py: - Reword module docstring to clarify the requirement is specifically for the frontmatter 'name' field value, not command files or invocation - Before: 'Requires hyphenated command names ... instead of dot notation' (implied dot notation unsupported overall) - After: 'Uses a hyphenated frontmatter name value ... for shell compatibility' (clarifies it's the frontmatter field, and Forge still supports dot filenames) Changes to tests/integrations/test_integration_forge.py: - Replace Claude with Windsurf in test_registrar_does_not_affect_other_agents - Claude uses build_skill_frontmatter() which always includes hyphenated names, so testing it didn't validate that format_name callback is Forge-only - Windsurf is a standard markdown agent without inject_name - Now asserts NO 'name:' field is present, proving format_name isn't invoked - This properly validates the callback mechanism is isolated to Forge * test(forge): use parse_frontmatter for precise YAML validation Replace regex and string searches with CommandRegistrar.parse_frontmatter() to validate only YAML frontmatter, not entire file content. Prevents false positives if command body contains 'name:' lines. Changes: - test_forge_specific_transformations: Parse frontmatter dict instead of string search - test_name_field_uses_hyphenated_format: Replace regex with frontmatter parsing - test_registrar_formats_extension_command_names_for_forge: Use dict validation - test_registrar_formats_alias_names_for_forge: Use dict validation Benefits: More precise, robust against body content, better error messages, consistent with existing codebase utilities. --------- Co-authored-by: ericnoam --- src/specify_cli/agents.py | 8 +- .../integrations/forge/__init__.py | 56 ++++- tests/integrations/test_integration_forge.py | 226 +++++++++++++++++- 3 files changed, 282 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 4b869283cc..ec7af88768 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -420,7 +420,9 @@ def register_commands( frontmatter.pop(key, None) if agent_config.get("inject_name") and not frontmatter.get("name"): - frontmatter["name"] = cmd_name + # Use custom name formatter if provided (e.g., Forge's hyphenated format) + format_name = agent_config.get("format_name") + frontmatter["name"] = format_name(cmd_name) if format_name else cmd_name body = self._convert_argument_placeholder( body, "$ARGUMENTS", agent_config["args"] @@ -454,7 +456,9 @@ def register_commands( # For agents with inject_name, render with alias-specific frontmatter if agent_config.get("inject_name"): alias_frontmatter = deepcopy(frontmatter) - alias_frontmatter["name"] = alias + # Use custom name formatter if provided (e.g., Forge's hyphenated format) + format_name = agent_config.get("format_name") + alias_frontmatter["name"] = format_name(alias) if format_name else alias if agent_config["extension"] == "/SKILL.md": alias_output = self.render_skill_command( diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index e3d5347270..e1c4d9da62 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -4,6 +4,7 @@ - Uses `{{parameters}}` instead of `$ARGUMENTS` for argument passing - Strips `handoffs` frontmatter key (Claude Code feature that causes Forge to hang) - Injects `name` field into frontmatter when missing +- Uses a hyphenated frontmatter `name` value (e.g., `speckit-foo-bar`) for shell compatibility, especially with ZSH """ from __future__ import annotations @@ -15,6 +16,52 @@ from ..manifest import IntegrationManifest +def format_forge_command_name(cmd_name: str) -> str: + """Convert command name to Forge-compatible hyphenated format. + + Forge requires command names to use hyphens instead of dots for + compatibility with ZSH and other shells. This function converts + dot-notation command names to hyphenated format. + + The function is idempotent: already-formatted names are returned unchanged. + + Examples: + >>> format_forge_command_name("plan") + 'speckit-plan' + >>> format_forge_command_name("speckit.plan") + 'speckit-plan' + >>> format_forge_command_name("speckit-plan") + 'speckit-plan' + >>> format_forge_command_name("speckit.my-extension.example") + 'speckit-my-extension-example' + >>> format_forge_command_name("speckit-my-extension-example") + 'speckit-my-extension-example' + >>> format_forge_command_name("speckit.jira.sync-status") + 'speckit-jira-sync-status' + + 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 + """ + # Already in hyphenated format - return as-is (idempotent) + if cmd_name.startswith("speckit-"): + return cmd_name + + # Strip 'speckit.' prefix if present + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + # Replace all dots with hyphens + short_name = short_name.replace(".", "-") + + # Return with 'speckit-' prefix + return f"speckit-{short_name}" + + class ForgeIntegration(MarkdownIntegration): """Integration for Forge (forgecode.dev). @@ -39,6 +86,7 @@ class ForgeIntegration(MarkdownIntegration): "extension": ".md", "strip_frontmatter_keys": ["handoffs"], "inject_name": True, + "format_name": format_forge_command_name, # Custom name formatter } context_file = "AGENTS.md" @@ -106,7 +154,7 @@ def _apply_forge_transformations(self, content: str, template_name: str) -> str: """Apply Forge-specific transformations to processed content. 1. Strip 'handoffs' frontmatter key (from Claude Code templates; incompatible with Forge) - 2. Inject 'name' field if missing + 2. Inject 'name' field if missing (using hyphenated format) """ # Parse frontmatter lines = content.split('\n') @@ -143,11 +191,11 @@ def _apply_forge_transformations(self, content: str, template_name: str) -> str: filtered_frontmatter.append(line) - # 2. Inject 'name' field if missing + # 2. Inject 'name' field if missing (using centralized formatter) has_name = any(line.strip().startswith('name:') for line in filtered_frontmatter) if not has_name: - # Use the template name as the command name (e.g., "plan" -> "speckit.plan") - cmd_name = f"speckit.{template_name}" + # Use centralized formatter to ensure consistent hyphenated format + cmd_name = format_forge_command_name(template_name) filtered_frontmatter.insert(0, f'name: {cmd_name}') # Reconstruct content diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 10905723fb..7affd0d160 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -2,6 +2,47 @@ from specify_cli.integrations import get_integration from specify_cli.integrations.manifest import IntegrationManifest +from specify_cli.integrations.forge import format_forge_command_name + + +class TestForgeCommandNameFormatter: + """Test the centralized Forge command name formatter.""" + + def test_simple_name_without_prefix(self): + """Test formatting a simple name without 'speckit.' prefix.""" + assert format_forge_command_name("plan") == "speckit-plan" + assert format_forge_command_name("tasks") == "speckit-tasks" + assert format_forge_command_name("specify") == "speckit-specify" + + def test_name_with_speckit_prefix(self): + """Test formatting a name that already has 'speckit.' prefix.""" + assert format_forge_command_name("speckit.plan") == "speckit-plan" + assert format_forge_command_name("speckit.tasks") == "speckit-tasks" + + def test_extension_command_name(self): + """Test formatting extension command names with dots.""" + assert format_forge_command_name("speckit.my-extension.example") == "speckit-my-extension-example" + assert format_forge_command_name("my-extension.example") == "speckit-my-extension-example" + + def test_complex_nested_name(self): + """Test formatting deeply nested command names.""" + assert format_forge_command_name("speckit.jira.sync-status") == "speckit-jira-sync-status" + assert format_forge_command_name("speckit.foo.bar.baz") == "speckit-foo-bar-baz" + + def test_name_with_hyphens_preserved(self): + """Test that existing hyphens are preserved.""" + assert format_forge_command_name("my-extension") == "speckit-my-extension" + assert format_forge_command_name("speckit.my-ext.test-cmd") == "speckit-my-ext-test-cmd" + + def test_alias_formatting(self): + """Test formatting alias names.""" + assert format_forge_command_name("speckit.my-extension.example-short") == "speckit-my-extension-example-short" + + def test_idempotent_already_hyphenated(self): + """Test that already-hyphenated names are returned unchanged (idempotent).""" + assert format_forge_command_name("speckit-plan") == "speckit-plan" + assert format_forge_command_name("speckit-my-extension-example") == "speckit-my-extension-example" + assert format_forge_command_name("speckit-jira-sync-status") == "speckit-jira-sync-status" class TestForgeIntegration: @@ -123,19 +164,22 @@ def test_templates_are_processed(self, tmp_path): def test_forge_specific_transformations(self, tmp_path): """Test Forge-specific processing: name injection and handoffs stripping.""" from specify_cli.integrations.forge import ForgeIntegration + from specify_cli.agents import CommandRegistrar forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) forge.setup(tmp_path, m) commands_dir = tmp_path / ".forge" / "commands" + registrar = CommandRegistrar() for cmd_file in commands_dir.glob("speckit.*.md"): content = cmd_file.read_text(encoding="utf-8") + frontmatter, _ = registrar.parse_frontmatter(content) # Check that name field is injected in frontmatter - assert "\nname: " in content, f"{cmd_file.name} missing injected 'name' field" + assert "name" in frontmatter, f"{cmd_file.name} missing injected 'name' field in frontmatter" # Check that handoffs frontmatter key is stripped - assert "\nhandoffs:" not in content, f"{cmd_file.name} has unstripped 'handoffs' key" + assert "handoffs" not in frontmatter, f"{cmd_file.name} has unstripped 'handoffs' key in frontmatter" def test_uses_parameters_placeholder(self, tmp_path): """Verify Forge replaces $ARGUMENTS with {{parameters}} in generated files.""" @@ -168,3 +212,181 @@ def test_uses_parameters_placeholder(self, tmp_path): assert "{{parameters}}" in content, ( "checklist should contain {{parameters}} in User Input section" ) + + def test_name_field_uses_hyphenated_format(self, tmp_path): + """Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan).""" + from specify_cli.integrations.forge import ForgeIntegration + from specify_cli.agents import CommandRegistrar + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + + # Check that name fields use hyphenated format + registrar = CommandRegistrar() + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + # Extract the name field from frontmatter using the parser + frontmatter, _ = registrar.parse_frontmatter(content) + assert "name" in frontmatter, ( + f"{cmd_file.name} missing injected 'name' field in frontmatter" + ) + name_value = frontmatter["name"] + # Name should use hyphens, not dots + assert "." not in name_value, ( + f"{cmd_file.name} has name field with dots: {name_value} " + f"(should use hyphens for Forge/ZSH compatibility)" + ) + assert name_value.startswith("speckit-"), ( + f"{cmd_file.name} name field should start with 'speckit-': {name_value}" + ) + + +class TestForgeCommandRegistrar: + """Test CommandRegistrar's Forge-specific name formatting.""" + + def test_registrar_formats_extension_command_names_for_forge(self, tmp_path): + """Verify CommandRegistrar converts dot notation to hyphens for Forge.""" + from specify_cli.agents import CommandRegistrar + + # Create a mock extension command file + ext_dir = tmp_path / "extension" + ext_dir.mkdir() + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir() + + # Create a test command with dot notation name + cmd_file = cmd_dir / "example.md" + cmd_file.write_text( + "---\n" + "description: Test extension command\n" + "---\n\n" + "Test content with $ARGUMENTS\n", + encoding="utf-8" + ) + + # Register with Forge + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.my-extension.example", + "file": "commands/example.md" + } + ] + + registered = registrar.register_commands( + "forge", + commands, + "test-extension", + ext_dir, + tmp_path + ) + + # Verify registration succeeded + assert "speckit.my-extension.example" in registered + + # Check the generated file has hyphenated name in frontmatter + forge_cmd = tmp_path / ".forge" / "commands" / "speckit.my-extension.example.md" + assert forge_cmd.exists() + + content = forge_cmd.read_text(encoding="utf-8") + # Parse frontmatter to validate name field precisely + frontmatter, _ = registrar.parse_frontmatter(content) + assert "name" in frontmatter, "name field should be injected in frontmatter" + # Name field should use hyphens, not dots + assert frontmatter["name"] == "speckit-my-extension-example" + + def test_registrar_formats_alias_names_for_forge(self, tmp_path): + """Verify CommandRegistrar converts alias names to hyphens for Forge.""" + from specify_cli.agents import CommandRegistrar + + # Create a mock extension command file + ext_dir = tmp_path / "extension" + ext_dir.mkdir() + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir() + + cmd_file = cmd_dir / "example.md" + cmd_file.write_text( + "---\n" + "description: Test command with alias\n" + "---\n\n" + "Test content\n", + encoding="utf-8" + ) + + # Register with Forge including an alias + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.my-extension.example", + "file": "commands/example.md", + "aliases": ["speckit.my-extension.ex"] + } + ] + + registrar.register_commands( + "forge", + commands, + "test-extension", + ext_dir, + tmp_path + ) + + # Check the alias file has hyphenated name in frontmatter + alias_file = tmp_path / ".forge" / "commands" / "speckit.my-extension.ex.md" + assert alias_file.exists() + + content = alias_file.read_text(encoding="utf-8") + # Parse frontmatter to validate alias name field precisely + frontmatter, _ = registrar.parse_frontmatter(content) + assert "name" in frontmatter, "name field should be injected in alias frontmatter" + # Alias name field should also use hyphens + assert frontmatter["name"] == "speckit-my-extension-ex" + + def test_registrar_does_not_affect_other_agents(self, tmp_path): + """Verify format_name callback is Forge-specific and doesn't affect other agents.""" + from specify_cli.agents import CommandRegistrar + + # Create a mock extension command file + ext_dir = tmp_path / "extension" + ext_dir.mkdir() + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir() + + cmd_file = cmd_dir / "example.md" + cmd_file.write_text( + "---\n" + "description: Test command\n" + "---\n\n" + "Test content with $ARGUMENTS\n", + encoding="utf-8" + ) + + # Register with Windsurf (standard markdown agent without inject_name) + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.my-extension.example", + "file": "commands/example.md" + } + ] + + registrar.register_commands( + "windsurf", + commands, + "test-extension", + ext_dir, + tmp_path + ) + + # Windsurf uses standard markdown format without name injection. + # The format_name callback should not be invoked for non-Forge agents. + windsurf_cmd = tmp_path / ".windsurf" / "workflows" / "speckit.my-extension.example.md" + assert windsurf_cmd.exists() + + content = windsurf_cmd.read_text(encoding="utf-8") + # Windsurf should NOT have a name field injected + assert "name:" not in content, ( + "Windsurf should not inject name field - format_name callback should be Forge-only" + ) From cb0d9612ef4a7fd67cca2c74698fc9f7fa813653 Mon Sep 17 00:00:00 2001 From: Sharath Satish <2109335+sharathsatish@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:55:57 +0530 Subject: [PATCH 011/184] feat: update fleet extension to v1.1.0 (#2029) --- extensions/catalog.community.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 8d91c56744..65fa17bb37 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -526,8 +526,8 @@ "id": "fleet", "description": "Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases.", "author": "sharathsatish", - "version": "1.0.0", - "download_url": "https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.0.0.zip", + "version": "1.1.0", + "download_url": "https://github.com/sharathsatish/spec-kit-fleet/archive/refs/tags/v1.1.0.zip", "repository": "https://github.com/sharathsatish/spec-kit-fleet", "homepage": "https://github.com/sharathsatish/spec-kit-fleet", "documentation": "https://github.com/sharathsatish/spec-kit-fleet/blob/main/README.md", @@ -550,7 +550,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-06T00:00:00Z", - "updated_at": "2026-03-06T00:00:00Z" + "updated_at": "2026-03-31T00:00:00Z" }, "iterate": { "name": "Iterate", From 1c41aacbacc2982aadcba304b9643b99fb7b3d81 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:49:19 -0500 Subject: [PATCH 012/184] fix: pin typer>=0.24.0 and click>=8.2.1 to fix import crash (#2136) typer <0.24.0 under-constrains its click dependency (click>=8.0.0), allowing resolvers to pick click <8.2 which lacks __class_getitem__ on click.Choice. This causes 'TypeError: type Choice is not subscriptable' at import time on any Python version. Pin typer>=0.24.0 (which correctly requires click>=8.2.1) and click>=8.2.1 to prevent incompatible combinations. Fixes #2134 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9e46b0a14d..56978061e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ version = "0.5.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ - "typer", - "click>=8.1", + "typer>=0.24.0", + "click>=8.2.1", "rich", "platformdirs", "readchar", From aa2282ea0431feb3e0d9bc6bd911a25b38709019 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:51:35 -0500 Subject: [PATCH 013/184] chore: release 0.5.1, begin 0.5.2.dev0 development (#2137) * chore: bump version to 0.5.1 * chore: begin 0.5.2.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2237f7fbf0..edfed9d4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ +## [0.5.1] - 2026-04-08 + +### Changed + +- fix: pin typer>=0.24.0 and click>=8.2.1 to fix import crash (#2136) +- feat: update fleet extension to v1.1.0 (#2029) +- fix(forge): use hyphen notation in frontmatter name field (#2075) +- fix(bash): sed replacement escaping, BSD portability, dead cleanup in update-agent-context.sh (#2090) +- Add Spec Diagram community extension to catalog and README (#2129) +- feat: Git extension stage 2 — GIT_BRANCH_NAME override, --force for existing dirs, auto-install tests (#1940) (#2117) +- fix(git): surface checkout errors for existing branches (#2122) +- Add Branch Convention community extension to catalog and README (#2128) +- docs: lighten March 2026 newsletter for readability (#2127) +- fix: restore alias compatibility for community extensions (#2110) (#2125) +- Added March 2026 newsletter (#2124) +- Add Spec Refine community extension to catalog and README (#2118) +- Add explicit-task-dependencies community preset to catalog and README (#2091) +- Add toc-navigation community preset to catalog and README (#2080) +- fix: prevent ambiguous TOML closing quotes when body ends with `"` (#2113) (#2115) +- fix speckit issue for trae (#2112) +- feat: Git extension stage 1 — bundled `extensions/git` with hooks on all core commands (#1941) +- Upgraded confluence extension to v.1.1.1 (#2109) +- Update V-Model Extension Pack to v0.5.0 (#2108) +- Add canon extension and canon-core preset. (#2022) +- [stage2] fix: serialize multiline descriptions in legacy TOML renderer (#2097) +- [stage1] fix: strip YAML frontmatter from TOML integration prompts (#2096) +- Add Confluence extension (#2028) +- fix: accept 4+ digit spec numbers in tests and docs (#2094) +- fix(scripts): improve git branch creation error handling (#2089) +- Add optimize extension to community catalog (#2088) +- feat: add "VS Code Ask Questions" preset (#2086) +- Add security-review v1.1.1 to community extensions catalog (#2073) +- Add `specify integration` subcommand for post-init integration management (#2083) +- Remove template version info from CLI, fix Claude user-invocable, cleanup dead code (#2081) +- fix: add user-invocable: true to skill frontmatter (#2077) +- fix: add actions:write permission to stale workflow (#2079) +- feat: add argument-hint frontmatter to Claude Code commands (#1951) (#2059) +- Update conduct extension to v1.0.1 (#2078) +- chore(deps): bump astral-sh/setup-uv from 7.6.0 to 8.0.0 (#2072) +- chore(deps): bump actions/configure-pages from 5 to 6 (#2071) +- feat: add spec-kit-fixit extension to community catalog (#2024) +- chore: release 0.5.0, begin 0.5.1.dev0 development (#2070) +- feat: add Forgecode agent support (#2034) + ## [0.5.0] - 2026-04-02 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 56978061e9..d7a55ef138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.5.1.dev0" +version = "0.5.2.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 55515093a25985ed601eabb39429c0528c1e72e8 Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Thu, 9 Apr 2026 21:14:21 +0800 Subject: [PATCH 014/184] feat: add memorylint extension to community catalog (#2138) * feat: add memorylint extension to community catalog * chore: update speckit_version requirement to >=0.5.1 for memorylint extension * docs: register memorylint extension in README and update requirements --- README.md | 1 + extensions/catalog.community.json | 36 +++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fd7b3ce960..dfa86965e7 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ The following community-contributed extensions are available in [`catalog.commun | MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) | | MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) | | MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) | +| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 65fa17bb37..023035d76e 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -870,6 +870,38 @@ "created_at": "2026-03-26T00:00:00Z", "updated_at": "2026-03-26T00:00:00Z" }, + "memorylint": { + "name": "MemoryLint", + "id": "memorylint", + "description": "Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution.", + "author": "RbBtSn0w", + "version": "1.0.0", + "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/RbBtSn0w/spec-kit-extensions", + "homepage": "https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint", + "documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/README.md", + "changelog": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.5.1" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "memory", + "governance", + "constitution", + "agents-md", + "process" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, "onboard": { "name": "Onboard", "id": "onboard", @@ -1623,4 +1655,4 @@ "updated_at": "2026-03-16T00:00:00Z" } } -} +} \ No newline at end of file From 66125a80a95007d732d5f04dac976b02c54ababd Mon Sep 17 00:00:00 2001 From: Alfredo Perez Date: Thu, 9 Apr 2026 10:07:30 -0500 Subject: [PATCH 015/184] docs: add SpecKit Companion to Community Friends section (#2140) Add SpecKit Companion VS Code extension to the Community Friends listing alongside existing community projects. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index dfa86965e7..9301fbaf82 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,8 @@ Community projects that extend, visualize, or build on Spec Kit: - **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. +- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. + ## 🤖 Supported AI Agents | Agent | Support | Notes | From 6af2e64e88b1371e65129b59afef3186e0ea7ed9 Mon Sep 17 00:00:00 2001 From: Dhilip Date: Thu, 9 Apr 2026 11:29:35 -0400 Subject: [PATCH 016/184] Rewrite AGENTS.md for integration architecture (#2119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rewrite AGENTS.md for integration subpackage architecture Replaces the old AGENT_CONFIG dict-based 7-step process with documentation reflecting the integration subpackage architecture shipped in #1924. Removed: Supported Agents table, old step-by-step guide referencing AGENT_CONFIG/release scripts/case statements, Agent Categories lists, Directory Conventions section, Important Design Decisions section. Kept: About Spec Kit and Specify, Command File Formats, Argument Patterns, Devcontainer section. Added: Architecture overview, decision tree for base class selection, configure/register/scripts/test/override steps with real code examples from existing integrations (Windsurf, Gemini, Codex, Copilot). Agent-Logs-Url: https://github.com/github/spec-kit/sessions/71b25c53-7d0c-492a-9503-f40a437d5ece Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Fix JSONC comment syntax in devcontainer example Agent-Logs-Url: https://github.com/github/spec-kit/sessions/71b25c53-7d0c-492a-9503-f40a437d5ece Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * docs(AGENTS.md): address Copilot PR review comments - Clarify that integrations are registered by _register_builtins() in __init__.py, not self-registered at import time - Scope the key-must-match-executable rule to CLI-based integrations (requires_cli: True); IDE-based integrations use canonical identifiers - Replace placeholder in test snippet with a concrete example path (.windsurf/workflows/) - Document that hyphens in keys become underscores in test filenames (e.g. cursor-agent -> test_integration_cursor_agent.py) - Note that the argument placeholder is integration-specific (registrar_config["args"]); add Forge's {{parameters}} as an example - Apply consistency fixes to Required fields table, Key design rule callout, and Common Pitfalls #1 * docs(AGENTS.md): clarify scripts path uses Python-safe package_dir not key The scripts step previously referenced src/specify_cli/integrations//scripts/ but for hyphenated keys the actual directory is underscored (e.g. kiro-cli -> kiro_cli/). Rename the placeholder to and add a note explaining: - matches for non-hyphenated keys - uses underscores for hyphenated keys (e.g. kiro-cli -> kiro_cli/) - IntegrationBase.key always retains the original hyphenated value Addresses: https://github.com/github/spec-kit/pull/2119#discussion_r3054946896 * docs(AGENTS.md): use in pytest example command The pytest command previously used as a placeholder, but test filenames always use underscores even for hyphenated keys. This was internally inconsistent since the preceding sentence already explained the hyphen→underscore mapping. Switch to to match the actual filename on disk. Addresses: https://github.com/github/spec-kit/pull/2119#discussion_r3054962863 * docs(AGENTS.md): use in step 2 subpackage path The path src/specify_cli/integrations//__init__.py was inaccurate for hyphenated keys (e.g. kiro-cli lives in kiro_cli/, not kiro-cli/). Rename the placeholder to , define it inline (hyphens become underscores), and note that IntegrationBase.key always retains the original hyphenated value. Addresses: https://github.com/github/spec-kit/pull/2119#discussion_r3058050583 * docs(AGENTS.md): qualify 'single source of truth' to Python metadata only The registry is only authoritative for Python integration metadata. Context-update dispatcher scripts (bash + PowerShell) still require explicit per-agent cases and maintain their own supported-agent lists until they are migrated to registry-based dispatch. Tighten the claim to avoid misleading contributors into skipping the script updates. Addresses: https://github.com/github/spec-kit/pull/2119#pullrequestreview-4083090261 * docs(AGENTS.md): mention ValidateSet update in PowerShell dispatcher step The update-agent-context.ps1 script has a [ValidateSet(...)] on the AgentType parameter. Without adding the new key to that list, the script rejects the argument before reaching Update-SpecificAgent. Add this as an explicit step alongside the switch case and Update-AllExistingAgents. Addresses: https://github.com/github/spec-kit/pull/2119#pullrequestreview-4083217694 * fix(integrations): sort codebuddy before codex in _register_builtins() Both the import list and the _register() call list had codex before codebuddy, violating the alphabetical ordering that AGENTS.md documents. Swap them so the file matches the documented convention. Addresses: https://github.com/github/spec-kit/pull/2119#pullrequestreview-4083341590 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --- AGENTS.md | 564 +++++++++-------------- src/specify_cli/integrations/__init__.py | 4 +- 2 files changed, 219 insertions(+), 349 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c7a06ea59b..27472ebec9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,277 +10,281 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their --- -## Adding New Agent Support - -This section explains how to add support for new AI agents/assistants to the Specify CLI. Use this guide as a reference when integrating new AI tools into the Spec-Driven Development workflow. - -### Overview - -Specify supports multiple AI agents by generating agent-specific command files and directory structures when initializing projects. Each agent has its own conventions for: - -- **Command file formats** (Markdown, TOML, etc.) -- **Directory structures** (`.claude/commands/`, `.windsurf/workflows/`, etc.) -- **Command invocation patterns** (slash commands, CLI tools, etc.) -- **Argument passing conventions** (`$ARGUMENTS`, `{{args}}`, etc.) - -### Current Supported Agents - -| Agent | Directory | Format | CLI Tool | Description | -| -------------------------- | ---------------------- | -------- | --------------- | --------------------------- | -| **Claude Code** | `.claude/commands/` | Markdown | `claude` | Anthropic's Claude Code CLI | -| **Gemini CLI** | `.gemini/commands/` | TOML | `gemini` | Google's Gemini CLI | -| **GitHub Copilot** | `.github/agents/` | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code | -| **Cursor** | `.cursor/commands/` | Markdown | N/A (IDE-based) | Cursor IDE (`--ai cursor-agent`) | -| **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI | -| **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI | -| **Codex CLI** | `.agents/skills/` | Markdown | `codex` | Codex CLI (`--ai codex --ai-skills`) | -| **Windsurf** | `.windsurf/workflows/` | Markdown | N/A (IDE-based) | Windsurf IDE workflows | -| **Junie** | `.junie/commands/` | Markdown | `junie` | Junie by JetBrains | -| **Kilo Code** | `.kilocode/workflows/` | Markdown | N/A (IDE-based) | Kilo Code IDE | -| **Auggie CLI** | `.augment/commands/` | Markdown | `auggie` | Auggie CLI | -| **Roo Code** | `.roo/commands/` | Markdown | N/A (IDE-based) | Roo Code IDE | -| **CodeBuddy CLI** | `.codebuddy/commands/` | Markdown | `codebuddy` | CodeBuddy CLI | -| **Qoder CLI** | `.qoder/commands/` | Markdown | `qodercli` | Qoder CLI | -| **Kiro CLI** | `.kiro/prompts/` | Markdown | `kiro-cli` | Kiro CLI | -| **Amp** | `.agents/commands/` | Markdown | `amp` | Amp CLI | -| **SHAI** | `.shai/commands/` | Markdown | `shai` | SHAI CLI | -| **Tabnine CLI** | `.tabnine/agent/commands/` | TOML | `tabnine` | Tabnine CLI | -| **Kimi Code** | `.kimi/skills/` | Markdown | `kimi` | Kimi Code CLI (Moonshot AI) | -| **Pi Coding Agent** | `.pi/prompts/` | Markdown | `pi` | Pi terminal coding agent | -| **iFlow CLI** | `.iflow/commands/` | Markdown | `iflow` | iFlow CLI (iflow-ai) | -| **Forge** | `.forge/commands/` | Markdown | `forge` | Forge CLI (forgecode.dev) | -| **IBM Bob** | `.bob/commands/` | Markdown | N/A (IDE-based) | IBM Bob IDE | -| **Trae** | `.trae/rules/` | Markdown | N/A (IDE-based) | Trae IDE | -| **Antigravity** | `.agent/commands/` | Markdown | N/A (IDE-based) | Antigravity IDE (`--ai agy --ai-skills`) | -| **Mistral Vibe** | `.vibe/prompts/` | Markdown | `vibe` | Mistral Vibe CLI | -| **Generic** | User-specified via `--ai-commands-dir` | Markdown | N/A | Bring your own agent | - -### Step-by-Step Integration Guide - -Follow these steps to add a new agent (using a hypothetical new agent as an example): - -#### 1. Add to AGENT_CONFIG - -**IMPORTANT**: Use the actual CLI tool name as the key, not a shortened version. - -Add the new agent to the `AGENT_CONFIG` dictionary in `src/specify_cli/__init__.py`. This is the **single source of truth** for all agent metadata: +## Integration Architecture + +Each AI agent is a self-contained **integration subpackage** under `src/specify_cli/integrations//`. The subpackage exposes a single class that declares all metadata and inherits setup/teardown logic from a base class. Built-in integrations are then instantiated and added to the global `INTEGRATION_REGISTRY` by `src/specify_cli/integrations/__init__.py` via `_register_builtins()`. -```python -AGENT_CONFIG = { - # ... existing agents ... - "new-agent-cli": { # Use the ACTUAL CLI tool name (what users type in terminal) - "name": "New Agent Display Name", - "folder": ".newagent/", # Directory for agent files - "commands_subdir": "commands", # Subdirectory name for command files (default: "commands") - "install_url": "https://example.com/install", # URL for installation docs (or None if IDE-based) - "requires_cli": True, # True if CLI tool required, False for IDE-based agents - }, -} +``` +src/specify_cli/integrations/ +├── __init__.py # INTEGRATION_REGISTRY + _register_builtins() +├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, SkillsIntegration +├── manifest.py # IntegrationManifest (file tracking) +├── claude/ # Example: SkillsIntegration subclass +│ ├── __init__.py # ClaudeIntegration class +│ └── scripts/ # Thin wrapper scripts +│ ├── update-context.sh +│ └── update-context.ps1 +├── gemini/ # Example: TomlIntegration subclass +│ ├── __init__.py +│ └── scripts/ +├── windsurf/ # Example: MarkdownIntegration subclass +│ ├── __init__.py +│ └── scripts/ +├── copilot/ # Example: IntegrationBase subclass (custom setup) +│ ├── __init__.py +│ └── scripts/ +└── ... # One subpackage per supported agent ``` -**Key Design Principle**: The dictionary key should match the actual executable name that users install. For example: +The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, and capabilities are derived from the integration classes for the Python integration layer. However, context-update behavior still requires explicit cases in the shared dispatcher scripts (`scripts/bash/update-agent-context.sh` and `scripts/powershell/update-agent-context.ps1`), which currently maintain their own supported-agent lists and agent-key→context-file mappings until they are migrated to registry-based dispatch. -- ✅ Use `"cursor-agent"` because the CLI tool is literally called `cursor-agent` -- ❌ Don't use `"cursor"` as a shortcut if the tool is `cursor-agent` +--- -This eliminates the need for special-case mappings throughout the codebase. +## Adding a New Integration -**Field Explanations**: +### 1. Choose a base class -- `name`: Human-readable display name shown to users -- `folder`: Directory where agent-specific files are stored (relative to project root) -- `commands_subdir`: Subdirectory name within the agent folder where command/prompt files are stored (default: `"commands"`) - - Most agents use `"commands"` (e.g., `.claude/commands/`) - - Some agents use alternative names: `"agents"` (copilot), `"workflows"` (windsurf, kilocode), `"prompts"` (codex, kiro-cli, pi), `"command"` (opencode - singular) - - This field enables `--ai-skills` to locate command templates correctly for skill generation -- `install_url`: Installation documentation URL (set to `None` for IDE-based agents) -- `requires_cli`: Whether the agent requires a CLI tool check during initialization +| Your agent needs… | Subclass | +|---|---| +| Standard markdown commands (`.md`) | `MarkdownIntegration` | +| TOML-format commands (`.toml`) | `TomlIntegration` | +| Skill directories (`speckit-/SKILL.md`) | `SkillsIntegration` | +| Fully custom output (companion files, settings merge, etc.) | `IntegrationBase` directly | -#### 2. Update CLI Help Text +Most agents only need `MarkdownIntegration` — a minimal subclass with zero method overrides. -Update the `--ai` parameter help text in the `init()` command to include the new agent: +### 2. Create the subpackage + +Create `src/specify_cli/integrations//__init__.py`, where `` is the Python-safe directory name derived from ``: use the key as-is when it contains no hyphens (e.g., key `"gemini"` → `gemini/`), or replace hyphens with underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value, since that is what the CLI and registry use. For CLI-based integrations (`requires_cli: True`), the `key` should match the actual CLI tool name (the executable users install and run) so CLI checks can resolve it correctly. For IDE-based integrations (`requires_cli: False`), use the canonical integration identifier instead. + +**Minimal example — Markdown agent (Windsurf):** ```python -ai_assistant: str = typer.Option(None, "--ai", help="AI assistant to use: claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, kilocode, auggie, codebuddy, new-agent-cli, or kiro-cli"), -``` +"""Windsurf IDE integration.""" -Also update any function docstrings, examples, and error messages that list available agents. +from ..base import MarkdownIntegration -#### 3. Update README Documentation -Update the **Supported AI Agents** section in `README.md` to include the new agent: +class WindsurfIntegration(MarkdownIntegration): + key = "windsurf" + config = { + "name": "Windsurf", + "folder": ".windsurf/", + "commands_subdir": "workflows", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".windsurf/workflows", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".windsurf/rules/specify-rules.md" +``` -- Add the new agent to the table with appropriate support level (Full/Partial) -- Include the agent's official website link -- Add any relevant notes about the agent's implementation -- Ensure the table formatting remains aligned and consistent +**TOML agent (Gemini):** -#### 4. Update Release Package Script +```python +"""Gemini CLI integration.""" -Modify `.github/workflows/scripts/create-release-packages.sh`: +from ..base import TomlIntegration -##### Add to ALL_AGENTS array -```bash -ALL_AGENTS=(claude gemini copilot cursor-agent qwen opencode windsurf kiro-cli) +class GeminiIntegration(TomlIntegration): + key = "gemini" + config = { + "name": "Gemini CLI", + "folder": ".gemini/", + "commands_subdir": "commands", + "install_url": "https://github.com/google-gemini/gemini-cli", + "requires_cli": True, + } + registrar_config = { + "dir": ".gemini/commands", + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + } + context_file = "GEMINI.md" ``` -##### Add case statement for directory structure +**Skills agent (Codex):** -```bash -case $agent in - # ... existing cases ... - windsurf) - mkdir -p "$base_dir/.windsurf/workflows" - generate_commands windsurf md "\$ARGUMENTS" "$base_dir/.windsurf/workflows" "$script" ;; -esac -``` +```python +"""Codex CLI integration — skills-based agent.""" -#### 4. Update GitHub Release Script +from __future__ import annotations -Modify `.github/workflows/scripts/create-github-release.sh` to include the new agent's packages: +from ..base import IntegrationOption, SkillsIntegration -```bash -gh release create "$VERSION" \ - # ... existing packages ... - .genreleases/spec-kit-template-windsurf-sh-"$VERSION".zip \ - .genreleases/spec-kit-template-windsurf-ps-"$VERSION".zip \ - # Add new agent packages here + +class CodexIntegration(SkillsIntegration): + key = "codex" + config = { + "name": "Codex CLI", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": "https://github.com/openai/codex", + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Codex)", + ), + ] ``` -#### 5. Update Agent Context Scripts +#### Required fields -##### Bash script (`scripts/bash/update-agent-context.sh`) +| Field | Location | Purpose | +|---|---|---| +| `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name | +| `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` | +| `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` | +| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) | -Add file variable: +**Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`). -```bash -WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" -``` +### 3. Register it -Add to case statement: +In `src/specify_cli/integrations/__init__.py`, add one import and one `_register()` call inside `_register_builtins()`. Both lists are alphabetical: -```bash -case "$AGENT_TYPE" in - # ... existing cases ... - windsurf) update_agent_file "$WINDSURF_FILE" "Windsurf" ;; - "") - # ... existing checks ... - [ -f "$WINDSURF_FILE" ] && update_agent_file "$WINDSURF_FILE" "Windsurf"; - # Update default creation condition - ;; -esac +```python +def _register_builtins() -> None: + # -- Imports (alphabetical) ------------------------------------------- + from .claude import ClaudeIntegration + # ... + from .newagent import NewAgentIntegration # ← add import + # ... + + # -- Registration (alphabetical) -------------------------------------- + _register(ClaudeIntegration()) + # ... + _register(NewAgentIntegration()) # ← add registration + # ... ``` -##### PowerShell script (`scripts/powershell/update-agent-context.ps1`) +### 4. Add scripts -Add file variable: +Create two thin wrapper scripts in `src/specify_cli/integrations//scripts/` that delegate to the shared context-update scripts. Each is ~25 lines of boilerplate. -```powershell -$windsurfFile = Join-Path $repoRoot '.windsurf/rules/specify-rules.md' +> **Note on `` vs ``:** `` is the Python-safe directory name for your integration — it matches `` exactly when the key contains no hyphens (e.g., key `"gemini"` → `gemini/`), but uses underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value (e.g., `key = "kiro-cli"`), since that is what the CLI and registry use. + +**`update-context.sh`:** + +```bash +#!/usr/bin/env bash +# update-context.sh — integration: create/update +set -euo pipefail + +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" ``` -Add to switch statement: +**`update-context.ps1`:** ```powershell -switch ($AgentType) { - # ... existing cases ... - 'windsurf' { Update-AgentFile $windsurfFile 'Windsurf' } - '' { - foreach ($pair in @( - # ... existing pairs ... - @{file=$windsurfFile; name='Windsurf'} - )) { - if (Test-Path $pair.file) { Update-AgentFile $pair.file $pair.name } - } - # Update default creation condition +# update-context.ps1 — integration: create/update +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot } } -``` - -#### 6. Update CLI Tool Checks (Optional) - -For agents that require CLI tools, add checks in the `check()` command and agent validation: -```python -# In check() command -tracker.add("windsurf", "Windsurf IDE (optional)") -windsurf_ok = check_tool_for_tracker("windsurf", "https://windsurf.com/", tracker) - -# In init validation (only if CLI tool required) -elif selected_ai == "windsurf": - if not check_tool("windsurf", "Install from: https://windsurf.com/"): - console.print("[red]Error:[/red] Windsurf CLI is required for Windsurf projects") - agent_tool_missing = True +& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType ``` -**Note**: CLI tool checks are now handled automatically based on the `requires_cli` field in AGENT_CONFIG. No additional code changes needed in the `check()` or `init()` commands - they automatically loop through AGENT_CONFIG and check tools as needed. - -## Important Design Decisions +Replace `` with your integration key and `` / `` with the appropriate values. -### Using Actual CLI Tool Names as Keys +You must also add the agent to the shared context-update scripts so the shared dispatcher recognises the new key: -**CRITICAL**: When adding a new agent to AGENT_CONFIG, always use the **actual executable name** as the dictionary key, not a shortened or convenient version. +- **`scripts/bash/update-agent-context.sh`** — add a file-path variable and a case in `update_specific_agent()`. +- **`scripts/powershell/update-agent-context.ps1`** — add a file-path variable, add the new key to the `AgentType` parameter's `[ValidateSet(...)]`, add a switch case in `Update-SpecificAgent`, and add an entry in `Update-AllExistingAgents`. -**Why this matters:** +### 5. Test it -- The `check_tool()` function uses `shutil.which(tool)` to find executables in the system PATH -- If the key doesn't match the actual CLI tool name, you'll need special-case mappings throughout the codebase -- This creates unnecessary complexity and maintenance burden +```bash +# Install into a test project +specify init my-project --integration -**Example - The Cursor Lesson:** +# Verify files were created in the commands directory configured by +# config["folder"] + config["commands_subdir"] (for example, .windsurf/workflows/) +ls -R my-project/.windsurf/workflows/ -❌ **Wrong approach** (requires special-case mapping): +# Uninstall cleanly +cd my-project && specify integration uninstall +``` -```python -AGENT_CONFIG = { - "cursor": { # Shorthand that doesn't match the actual tool - "name": "Cursor", - # ... - } -} +Each integration also has a dedicated test file at `tests/integrations/test_integration_.py`. Note that hyphens in the key are replaced with underscores in the filename (e.g., key `cursor-agent` → `test_integration_cursor_agent.py`, key `kiro-cli` → `test_integration_kiro_cli.py`). Run it with: -# Then you need special cases everywhere: -cli_tool = agent_key -if agent_key == "cursor": - cli_tool = "cursor-agent" # Map to the real tool name +```bash +pytest tests/integrations/test_integration_.py -v ``` -✅ **Correct approach** (no mapping needed): +### 6. Optional overrides -```python -AGENT_CONFIG = { - "cursor-agent": { # Matches the actual executable name - "name": "Cursor", - # ... - } -} +The base classes handle most work automatically. Override only when the agent deviates from standard patterns: -# No special cases needed - just use agent_key directly! -``` +| Override | When to use | Example | +|---|---|---| +| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` | +| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag | +| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` | +| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files | -**Benefits of this approach:** +**Example — Copilot (fully custom `setup`):** -- Eliminates special-case logic scattered throughout the codebase -- Makes the code more maintainable and easier to understand -- Reduces the chance of bugs when adding new agents -- Tool checking "just works" without additional mappings +Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation. -#### 7. Update Devcontainer files (Optional) +### 7. Update Devcontainer files (Optional) For agents that have VS Code extensions or require CLI installation, update the devcontainer configuration files: -##### VS Code Extension-based Agents +#### VS Code Extension-based Agents For agents available as VS Code extensions, add them to `.devcontainer/devcontainer.json`: -```json +```jsonc { "customizations": { "vscode": { "extensions": [ // ... existing extensions ... - // [New Agent Name] "[New Agent Extension ID]" ] } @@ -288,7 +292,7 @@ For agents available as VS Code extensions, add them to `.devcontainer/devcontai } ``` -##### CLI-based Agents +#### CLI-based Agents For agents that require CLI tools, add installation commands to `.devcontainer/post-create.sh`: @@ -298,63 +302,16 @@ For agents that require CLI tools, add installation commands to `.devcontainer/p # Existing installations... echo -e "\n🤖 Installing [New Agent Name] CLI..." -# run_command "npm install -g [agent-cli-package]@latest" # Example for node-based CLI -# or other installation instructions (must be non-interactive and compatible with Linux Debian "Trixie" or later)... +# run_command "npm install -g [agent-cli-package]@latest" echo "✅ Done" - ``` -**Quick Tips:** - -- **Extension-based agents**: Add to the `extensions` array in `devcontainer.json` -- **CLI-based agents**: Add installation scripts to `post-create.sh` -- **Hybrid agents**: May require both extension and CLI installation -- **Test thoroughly**: Ensure installations work in the devcontainer environment - -## Agent Categories - -### CLI-Based Agents - -Require a command-line tool to be installed: - -- **Claude Code**: `claude` CLI -- **Gemini CLI**: `gemini` CLI -- **Qwen Code**: `qwen` CLI -- **opencode**: `opencode` CLI -- **Codex CLI**: `codex` CLI (requires `--ai-skills`) -- **Junie**: `junie` CLI -- **Auggie CLI**: `auggie` CLI -- **CodeBuddy CLI**: `codebuddy` CLI -- **Qoder CLI**: `qodercli` CLI -- **Kiro CLI**: `kiro-cli` CLI -- **Amp**: `amp` CLI -- **SHAI**: `shai` CLI -- **Tabnine CLI**: `tabnine` CLI -- **Kimi Code**: `kimi` CLI -- **Mistral Vibe**: `vibe` CLI -- **Pi Coding Agent**: `pi` CLI -- **iFlow CLI**: `iflow` CLI -- **Forge**: `forge` CLI - -### IDE-Based Agents - -Work within integrated development environments: - -- **GitHub Copilot**: Built into VS Code/compatible editors -- **Cursor**: Built into Cursor IDE (`--ai cursor-agent`) -- **Windsurf**: Built into Windsurf IDE -- **Kilo Code**: Built into Kilo Code IDE -- **Roo Code**: Built into Roo Code IDE -- **IBM Bob**: Built into IBM Bob IDE -- **Trae**: Built into Trae IDE -- **Antigravity**: Built into Antigravity IDE (`--ai agy --ai-skills`) +--- ## Command File Formats ### Markdown Format -Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow, Forge - **Standard format:** ```markdown @@ -378,8 +335,6 @@ Command content with {SCRIPT} and $ARGUMENTS placeholders. ### TOML Format -Used by: Gemini, Tabnine - ```toml description = "Command description" @@ -388,109 +343,24 @@ Command content with {SCRIPT} and {{args}} placeholders. """ ``` -## Directory Conventions - -- **CLI agents**: Usually `./commands/` -- **Singular command exception**: - - opencode: `.opencode/command/` (singular `command`, not `commands`) -- **Nested path exception**: - - Tabnine: `.tabnine/agent/commands/` (extra `agent/` segment) -- **Shared `.agents/` folder**: - - Amp: `.agents/commands/` (shared folder, not `.amp/`) - - Codex: `.agents/skills/` (shared folder; requires `--ai-skills`; invoked as `$speckit-`) -- **Skills-based exceptions**: - - Kimi Code: `.kimi/skills/` (skills, invoked as `/skill:speckit-`) -- **Prompt-based exceptions**: - - Kiro CLI: `.kiro/prompts/` - - Pi: `.pi/prompts/` - - Mistral Vibe: `.vibe/prompts/` -- **Rules-based exceptions**: - - Trae: `.trae/rules/` -- **IDE agents**: Follow IDE-specific patterns: - - Copilot: `.github/agents/` - - Cursor: `.cursor/commands/` - - Windsurf: `.windsurf/workflows/` - - Kilo Code: `.kilocode/workflows/` - - Roo Code: `.roo/commands/` - - IBM Bob: `.bob/commands/` - - Antigravity: `.agent/skills/` (`--ai-skills` required; `.agent/commands/` is deprecated) - ## Argument Patterns -Different agents use different argument placeholders: +Different agents use different argument placeholders. The placeholder used in command files is always taken from `registrar_config["args"]` for each integration — check there first when in doubt: -- **Markdown/prompt-based**: `$ARGUMENTS` -- **TOML-based**: `{{args}}` -- **Forge-specific**: `{{parameters}}` (uses custom parameter syntax) +- **Markdown/prompt-based**: `$ARGUMENTS` (default for most markdown agents) +- **TOML-based**: `{{args}}` (e.g., Gemini) +- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`) - **Script placeholders**: `{SCRIPT}` (replaced with actual script path) - **Agent placeholders**: `__AGENT__` (replaced with agent name) -## Special Processing Requirements - -Some agents require custom processing beyond the standard template transformations: - -### Copilot Integration - -GitHub Copilot has unique requirements: -- Commands use `.agent.md` extension (not `.md`) -- Each command gets a companion `.prompt.md` file in `.github/prompts/` -- Installs `.vscode/settings.json` with prompt file recommendations -- Context file lives at `.github/copilot-instructions.md` - -Implementation: Extends `IntegrationBase` with custom `setup()` method that: -1. Processes templates with `process_template()` -2. Generates companion `.prompt.md` files -3. Merges VS Code settings - -### Forge Integration - -Forge has special frontmatter and argument requirements: -- Uses `{{parameters}}` instead of `$ARGUMENTS` -- Strips `handoffs` frontmatter key (Forge-specific collaboration feature) -- Injects `name` field into frontmatter when missing - -Implementation: Extends `MarkdownIntegration` with custom `setup()` method that: -1. Inherits standard template processing from `MarkdownIntegration` -2. Adds extra `$ARGUMENTS` → `{{parameters}}` replacement after template processing -3. Applies Forge-specific transformations via `_apply_forge_transformations()` -4. Strips `handoffs` frontmatter key -5. Injects missing `name` fields -6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` (similar to `opencode`/`codex`/`pi`) and lists `forge` in their usage/help text - -### Standard Markdown Agents - -Most agents (Bob, Claude, Windsurf, etc.) use `MarkdownIntegration`: -- Simple subclass with just `key`, `config`, `registrar_config` set -- Inherits standard processing from `MarkdownIntegration.setup()` -- No custom processing needed - -## Testing New Agent Integration - -1. **Build test**: Run package creation script locally -2. **CLI test**: Test `specify init --ai ` command -3. **File generation**: Verify correct directory structure and files -4. **Command validation**: Ensure generated commands work with the agent -5. **Context update**: Test agent context update scripts - ## Common Pitfalls -1. **Using shorthand keys instead of actual CLI tool names**: Always use the actual executable name as the AGENT_CONFIG key (e.g., `"cursor-agent"` not `"cursor"`). This prevents the need for special-case mappings throughout the codebase. -2. **Forgetting update scripts**: Both bash and PowerShell scripts must be updated when adding new agents. -3. **Incorrect `requires_cli` value**: Set to `True` only for agents that actually have CLI tools to check; set to `False` for IDE-based agents. -4. **Wrong argument format**: Use correct placeholder format for each agent type (`$ARGUMENTS` for Markdown, `{{args}}` for TOML). -5. **Directory naming**: Follow agent-specific conventions exactly (check existing agents for patterns). -6. **Help text inconsistency**: Update all user-facing text consistently (help strings, docstrings, README, error messages). - -## Future Considerations - -When adding new agents: - -- Consider the agent's native command/workflow patterns -- Ensure compatibility with the Spec-Driven Development process -- Document any special requirements or limitations -- Update this guide with lessons learned -- Verify the actual CLI tool name before adding to AGENT_CONFIG +1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint. +2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated. +3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents. +4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents. +5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added. --- -*This documentation should be updated whenever new agents are added to maintain accuracy and completeness.* +*This documentation should be updated whenever new integrations are added to maintain accuracy and completeness.* diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index c65013869e..3eb58622e7 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -51,8 +51,8 @@ def _register_builtins() -> None: from .auggie import AuggieIntegration from .bob import BobIntegration from .claude import ClaudeIntegration - from .codex import CodexIntegration from .codebuddy import CodebuddyIntegration + from .codex import CodexIntegration from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration from .forge import ForgeIntegration @@ -80,8 +80,8 @@ def _register_builtins() -> None: _register(AuggieIntegration()) _register(BobIntegration()) _register(ClaudeIntegration()) - _register(CodexIntegration()) _register(CodebuddyIntegration()) + _register(CodexIntegration()) _register(CopilotIntegration()) _register(CursorAgentIntegration()) _register(ForgeIntegration()) From 0a121b073cdc2ddfd4fa69034a1d0cf8aead57f9 Mon Sep 17 00:00:00 2001 From: PChemGuy <39730837+pchemguy@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:23:10 +0300 Subject: [PATCH 017/184] Readme clarity (#2013) * Specify CLI Reference formatting Improves formatting of Specify CLI Reference * Available Slash Commands clarity Improve "Available Slash Commands" clarity in README.md. * Add extension/preset commands to cli reference * Extensions & Presets section clarity Improves Extensions & Presets section clarity in README.md * Removes `$` from Agent Skill * Reverts Supported AI Agents Table * Added missing Agent Skill column * Trailing whitespaces Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Adds missing code block language Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Revised wording Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Revised specify synopsis * Update specify command reference table Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Removes extra (duplicate) slashes * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Removed old section * missing /speckit.taskstoissues * integration command Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 115 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 9301fbaf82..22518ca432 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,6 @@ Community projects that extend, visualize, or build on Spec Kit: - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. ## 🤖 Supported AI Agents - | Agent | Support | Notes | | ------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | [Qoder CLI](https://qoder.com/cli) | ✅ | | @@ -321,22 +320,63 @@ Community projects that extend, visualize, or build on Spec Kit: | [Trae](https://www.trae.ai/) | ✅ | | | Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | +## Available Slash Commands + +After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`. + +#### Core Commands + +Essential commands for the Spec-Driven Development workflow: + +| Command | Agent Skill | Description | +| ------------------------ | ---------------------- | -------------------------------------------------------------------------- | +| `/speckit.constitution` | `speckit-constitution` | Create or update project governing principles and development guidelines | +| `/speckit.specify` | `speckit-specify` | Define what you want to build (requirements and user stories) | +| `/speckit.plan` | `speckit-plan` | Create technical implementation plans with your chosen tech stack | +| `/speckit.tasks` | `speckit-tasks` | Generate actionable task lists for implementation | +| `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution | +| `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan | + +#### Optional Commands + +Additional commands for enhanced quality and validation: + +| Command | Agent Skill | Description | +| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `/speckit.clarify` | `speckit-clarify` | Clarify underspecified areas (recommended before `/speckit.plan`; formerly `/quizme`) | +| `/speckit.analyze` | `speckit-analyze` | Cross-artifact consistency & coverage analysis (run after `/speckit.tasks`, before `/speckit.implement`) | +| `/speckit.checklist` | `speckit-checklist` | Generate custom quality checklists that validate requirements completeness, clarity, and consistency (like "unit tests for English") | + ## 🔧 Specify CLI Reference -The `specify` command supports the following options: +The `specify` tool is invoked as + +```text +specify [SUBCOMMAND] [OPTIONS] +``` + +and supports the following commands: ### Commands -| Command | Description | -| ------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `init` | Initialize a new Specify project from the latest template | -| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, etc.) | +| Command | Description | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `init` | Initialize a new Specify project from the latest template. | +| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, etc.) | +| `version` | Show the currently installed Spec Kit version. | +| `extension` | Manage extensions | +| `preset` | Manage presets | +| `integration` | Manage integrations | ### `specify init` Arguments & Options +```bash +specify init [PROJECT_NAME] +``` + | Argument/Option | Type | Description | -| ---------------------- | -------- |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | +| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | | `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, or `generic` (requires `--ai-commands-dir`) | | `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | | `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | @@ -433,38 +473,6 @@ specify init my-project --ai claude --branch-numbering timestamp specify check ``` -### Available Slash Commands - -After running `specify init`, your AI coding agent will have access to these structured development commands. - -Most agents expose the traditional dotted slash commands shown below, like `/speckit.plan`. - -Claude Code installs spec-kit as skills and invokes them as `/speckit-constitution`, `/speckit-specify`, `/speckit-plan`, `/speckit-tasks`, and `/speckit-implement`. - -For Codex CLI, `--ai-skills` installs spec-kit as agent skills instead of slash-command prompt files. In Codex skills mode, invoke spec-kit as `$speckit-constitution`, `$speckit-specify`, `$speckit-plan`, `$speckit-tasks`, and `$speckit-implement`. - -#### Core Commands - -Essential commands for the Spec-Driven Development workflow: - -| Command | Description | -| ----------------------- | ------------------------------------------------------------------------ | -| `/speckit.constitution` | Create or update project governing principles and development guidelines | -| `/speckit.specify` | Define what you want to build (requirements and user stories) | -| `/speckit.plan` | Create technical implementation plans with your chosen tech stack | -| `/speckit.tasks` | Generate actionable task lists for implementation | -| `/speckit.implement` | Execute all tasks to build the feature according to the plan | - -#### Optional Commands - -Additional commands for enhanced quality and validation: - -| Command | Description | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `/speckit.clarify` | Clarify underspecified areas (recommended before `/speckit.plan`; formerly `/quizme`) | -| `/speckit.analyze` | Cross-artifact consistency & coverage analysis (run after `/speckit.tasks`, before `/speckit.implement`) | -| `/speckit.checklist` | Generate custom quality checklists that validate requirements completeness, clarity, and consistency (like "unit tests for English") | - ### Environment Variables | Variable | Description | @@ -475,21 +483,18 @@ Additional commands for enhanced quality and validation: Spec Kit can be tailored to your needs through two complementary systems — **extensions** and **presets** — plus project-local overrides for one-off adjustments: -```mermaid -block-beta - columns 1 - overrides["⬆ Highest priority\nProject-Local Overrides\n.specify/templates/overrides/"] - presets["Presets — Customize core & extensions\n.specify/presets//templates/"] - extensions["Extensions — Add new capabilities\n.specify/extensions//templates/"] - core["Spec Kit Core — Built-in SDD commands & templates\n.specify/templates/\n⬇ Lowest priority"] - - style overrides fill:transparent,stroke:#999 - style presets fill:transparent,stroke:#4a9eda - style extensions fill:transparent,stroke:#4a9e4a - style core fill:transparent,stroke:#e6a817 -``` - -**Templates** are resolved at **runtime** — Spec Kit walks the stack top-down and uses the first match. Project-local overrides (`.specify/templates/overrides/`) let you make one-off adjustments for a single project without creating a full preset. **Commands** are applied at **install time** — when you run `specify extension add` or `specify preset add`, command files are written into agent directories (e.g., `.claude/commands/`). If multiple presets or extensions provide the same command, the highest-priority version wins. On removal, the next-highest-priority version is restored automatically. If no overrides or customizations exist, Spec Kit uses its core defaults. +| Priority | Component Type | Location | +| -------: | ------------------------------------------------- | -------------------------------- | +| ⬆ 1 | Project-Local Overrides | `.specify/templates/overrides/` | +| 2 | Presets — Customize core & extensions | `.specify/presets/templates/` | +| 3 | Extensions — Add new capabilities | `.specify/extensions/templates/` | +| ⬇ 4 | Spec Kit Core — Built-in SDD commands & templates | `.specify/templates/` | + +- **Templates** are resolved at **runtime** — Spec Kit walks the stack top-down and uses the first match. +- Project-local overrides (`.specify/templates/overrides/`) let you make one-off adjustments for a single project without creating a full preset. +- **Extension/preset commands** are applied at **install time** — when you run `specify extension add` or `specify preset add`, command files are written into agent directories (e.g., `.claude/commands/`). +- If multiple presets or extensions provide the same command, the highest-priority version wins. On removal, the next-highest-priority version is restored automatically. +- If no overrides or customizations exist, Spec Kit uses its core defaults. ### Extensions — Add New Capabilities From 8013d0b57e0ccdee635dfe8d3efcad4aed483f41 Mon Sep 17 00:00:00 2001 From: Sakit Date: Thu, 9 Apr 2026 22:35:03 +0400 Subject: [PATCH 018/184] Add multi-repo-branching preset to community catalog (#2139) * Add nested-repos to community catalog - Preset ID: nested-repos - Version: 1.0.0 - Author: sakitA - Description: Multi-module nested repository support for independent repos and git submodules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Bump catalog updated_at timestamp Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update nested-repos preset: commands-only, 0 templates Removed template overrides to reduce core content duplication. Commands instruct AI to add nested-repo sections dynamically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename preset: nested-repos -> multi-repo-branching Updated preset ID, name, description, and all URLs to reflect the new repository name and clearer preset identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove templates: 0 from catalog provides section Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove accidentally committed .claude folder Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: restore templates key and add README entry - Add templates: 0 back to provides for catalog consistency - Add multi-repo-branching to Community Presets table in README.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + presets/catalog.community.json | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 22518ca432..2eb60af31c 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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) | | 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) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | +| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 625bc9ed50..378b264c17 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-06T06:30:00Z", + "updated_at": "2026-04-09T08:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "aide-in-place": { @@ -78,6 +78,33 @@ "wave-dag" ] }, + "multi-repo-branching": { + "name": "Multi-Repo Branching", + "id": "multi-repo-branching", + "version": "1.0.0", + "description": "Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases.", + "author": "sakitA", + "repository": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching", + "download_url": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching", + "documentation": "https://github.com/sakitA/spec-kit-preset-multi-repo-branching/blob/master/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "templates": 0, + "commands": 2 + }, + "tags": [ + "multi-repo-branching", + "multi-module", + "submodules", + "monorepo" + ], + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, "pirate": { "name": "Pirate Speak (Full)", "id": "pirate", From efeb5489c3276c9ad18ffdbd7b9d457d07dc45a5 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 9 Apr 2026 23:53:54 +0500 Subject: [PATCH 019/184] Add Worktree Isolation extension to community catalog (#2143) - 3 commands: create, list, clean worktrees for parallel feature development - 1 hook: after_specify for auto-worktree creation - Addresses community request in issue #61 (36+ upvotes) --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 2eb60af31c..b672cf0f72 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ The following community-contributed extensions are available in [`catalog.commun | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | +| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) | To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md). diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 023035d76e..aa94a09066 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1653,6 +1653,38 @@ "stars": 0, "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-16T00:00:00Z" + }, + "worktree": { + "name": "Worktree Isolation", + "id": "worktree", + "description": "Spawn isolated git worktrees for parallel feature development without checkout switching.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-worktree/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-worktree", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-worktree", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-worktree/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-worktree/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "worktree", + "git", + "parallel", + "isolation", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" } } } \ No newline at end of file From 674a66449ae2c9d2fa058c4aae2fe32c059a80df Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 00:02:03 +0500 Subject: [PATCH 020/184] Add Bugfix Workflow community extension to catalog and README (#2135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Bugfix Workflow community extension to catalog and README Adds the spec-kit-bugfix extension (3 commands, 1 hook) that provides a structured bugfix workflow — capture bugs, trace to spec artifacts, and surgically patch specs without regenerating from scratch. Addresses community request in issue #619 (25+ upvotes, maintainer-approved). Co-Authored-By: Claude Sonnet 4.6 * Bump catalog updated_at to 2026-04-09 to match new entry date Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index b672cf0f72..064554c39c 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ The following community-contributed extensions are available in [`catalog.commun | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | +| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index aa94a09066..87f2d56347 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -138,6 +138,38 @@ "created_at": "2026-04-08T00:00:00Z", "updated_at": "2026-04-08T00:00:00Z" }, + "bugfix": { + "name": "Bugfix Workflow", + "id": "bugfix", + "description": "Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-bugfix/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-bugfix", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-bugfix", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-bugfix/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-bugfix/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "bugfix", + "debugging", + "workflow", + "traceability", + "maintenance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T00:00:00Z" + }, "canon": { "name": "Canon", "id": "canon", From e70495c2b8bc4d32f8165b7ea01dc84a17747e0f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:17:11 -0500 Subject: [PATCH 021/184] chore: release 0.6.0, begin 0.6.1.dev0 development (#2144) * chore: bump version to 0.6.0 * chore: begin 0.6.1.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edfed9d4ab..139221d075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ +## [0.6.0] - 2026-04-09 + +### Changed + +- Add Bugfix Workflow community extension to catalog and README (#2135) +- Add Worktree Isolation extension to community catalog (#2143) +- Add multi-repo-branching preset to community catalog (#2139) +- Readme clarity (#2013) +- Rewrite AGENTS.md for integration architecture (#2119) +- docs: add SpecKit Companion to Community Friends section (#2140) +- feat: add memorylint extension to community catalog (#2138) +- chore: release 0.5.1, begin 0.5.2.dev0 development (#2137) + ## [0.5.1] - 2026-04-08 ### Changed diff --git a/pyproject.toml b/pyproject.toml index d7a55ef138..e43f812724 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.5.2.dev0" +version = "0.6.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From bc0288832e78d1226c8a87c276c934a87b18a109 Mon Sep 17 00:00:00 2001 From: Wes Etheredge Date: Fri, 10 Apr 2026 07:27:15 -0500 Subject: [PATCH 022/184] Add Status Report extension to community catalog (#2123) - Extension ID: status-report - Version: 1.2.5 - Author: Open-Agent-Tools - Description: Project status, feature progress, and next-action recommendations for spec-driven workflows Co-authored-by: Unserious AI <121459476+unseriousAI@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 1 + extensions/catalog.community.json | 32 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 064554c39c..b389dde795 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | +| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 87f2d56347..cd37ef3d02 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-09T00:00:00Z", + "updated_at": "2026-04-09T14:30:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1516,6 +1516,36 @@ "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-16T00:00:00Z" }, + "status-report": { + "name": "Status Report", + "id": "status-report", + "description": "Project status, feature progress, and next-action recommendations for spec-driven workflows.", + "author": "Open-Agent-Tools", + "version": "1.2.5", + "download_url": "https://github.com/Open-Agent-Tools/spec-kit-status/archive/refs/tags/v1.2.5.zip", + "repository": "https://github.com/Open-Agent-Tools/spec-kit-status", + "homepage": "https://github.com/Open-Agent-Tools/spec-kit-status", + "documentation": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/README.md", + "changelog": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "workflow", + "project-management", + "status" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-08T15:05:14Z", + "updated_at": "2026-04-08T15:05:14Z" + }, "superb": { "name": "Superpowers Bridge", "id": "superb", From 7f1e38491ff170d2764f2a5f3e1ad1e436c3c288 Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:32:48 +0200 Subject: [PATCH 023/184] chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146) * chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 * Removed invalid aliases * Change updated at --- extensions/catalog.community.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index cd37ef3d02..848ce79e78 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1331,8 +1331,8 @@ "id": "review", "description": "Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification.", "author": "ismaelJimenez", - "version": "1.0.0", - "download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.0.zip", + "version": "1.0.1", + "download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.1.zip", "repository": "https://github.com/ismaelJimenez/spec-kit-review", "homepage": "https://github.com/ismaelJimenez/spec-kit-review", "documentation": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/README.md", @@ -1358,7 +1358,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-06T00:00:00Z", - "updated_at": "2026-03-06T00:00:00Z" + "updated_at": "2026-04-09T00:00:00Z" }, "security-review": { "name": "Security Review", @@ -1658,8 +1658,8 @@ "id": "verify", "description": "Post-implementation quality gate that validates implemented code against specification artifacts.", "author": "ismaelJimenez", - "version": "1.0.0", - "download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.0.zip", + "version": "1.0.3", + "download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.3.zip", "repository": "https://github.com/ismaelJimenez/spec-kit-verify", "homepage": "https://github.com/ismaelJimenez/spec-kit-verify", "documentation": "https://github.com/ismaelJimenez/spec-kit-verify/blob/main/README.md", @@ -1683,7 +1683,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-03T00:00:00Z", - "updated_at": "2026-03-03T00:00:00Z" + "updated_at": "2026-04-09T00:00:00Z" }, "verify-tasks": { "name": "Verify Tasks Extension", From b6e19b49ec0e6e718fd86ee7fadeaa29724ea8b5 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 19:10:42 +0500 Subject: [PATCH 024/184] Add TinySpec extension to community catalog (#2147) * Add TinySpec extension to community catalog - 3 commands: tinyspec, implement, classify for lightweight single-file workflow - 1 hook: before_specify for auto-classifying task complexity - Addresses community request in issue #1174 (22+ reactions) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b389dde795..0ca2da7663 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ The following community-contributed extensions are available in [`catalog.commun | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | | Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | +| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 848ce79e78..942a5605f7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-09T14:30:00Z", + "updated_at": "2026-04-10T12:34:56Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1621,6 +1621,38 @@ "created_at": "2026-03-02T00:00:00Z", "updated_at": "2026-03-02T00:00:00Z" }, + "tinyspec": { + "name": "TinySpec", + "id": "tinyspec", + "description": "Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-tinyspec", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-tinyspec", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "lightweight", + "small-tasks", + "workflow", + "productivity", + "efficiency" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", From 5732de60d0297f67d12d4786ed4e1205ee977ac1 Mon Sep 17 00:00:00 2001 From: Gabriel Henrique <51464658+gabrielhmsantos@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:35:19 -0300 Subject: [PATCH 025/184] feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156) Use SkillsIntegration so workflows ship as speckit-*/SKILL.md. Update init next-steps, extension hook invocation, docs, and tests. Made-with: Cursor --- docs/upgrade.md | 2 +- src/specify_cli/__init__.py | 10 +++++-- src/specify_cli/extensions.py | 3 ++ .../integrations/cursor_agent/__init__.py | 30 +++++++++++++++---- .../test_integration_cursor_agent.py | 25 +++++++++++++--- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/docs/upgrade.md b/docs/upgrade.md index cd5cc124fe..aecbb7879b 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -292,7 +292,7 @@ This tells Spec Kit which feature directory to use when creating specs, plans, a ```bash ls -la .claude/commands/ # Claude Code ls -la .gemini/commands/ # Gemini - ls -la .cursor/commands/ # Cursor + ls -la .cursor/skills/ # Cursor ls -la .pi/prompts/ # Pi Coding Agent ``` diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 11b6e0eda5..7f343f7a14 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1338,7 +1338,7 @@ def init( step_num = 2 # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, kimi, agy, trae) should show skill invocation syntax. + # Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax. from .integrations.base import SkillsIntegration as _SkillsInt _is_skills_integration = isinstance(resolved_integration, _SkillsInt) @@ -1347,7 +1347,8 @@ def init( kimi_skill_mode = selected_ai == "kimi" agy_skill_mode = selected_ai == "agy" and _is_skills_integration trae_skill_mode = selected_ai == "trae" - native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode + cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) + 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 if codex_skill_mode and not ai_skills: # Integration path installed skills; show the helpful notice @@ -1356,6 +1357,9 @@ def init( if claude_skill_mode and not ai_skills: steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]") step_num += 1 + if cursor_agent_skill_mode and not ai_skills: + steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]") + step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: @@ -1365,6 +1369,8 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" + if cursor_agent_skill_mode: + return f"/speckit-{name}" return f"/speckit.{name}" steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index da1a5f4472..d03018b024 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2170,6 +2170,7 @@ def _render_hook_invocation(self, command: Any) -> str: codex_skill_mode = selected_ai == "codex" 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" + cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills")) skill_name = self._skill_name_from_command(command_id) if codex_skill_mode and skill_name: @@ -2178,6 +2179,8 @@ def _render_hook_invocation(self, command: Any) -> str: return f"/{skill_name}" if kimi_skill_mode and skill_name: return f"/skill:{skill_name}" + if cursor_skill_mode and skill_name: + return f"/{skill_name}" return f"/{command_id}" diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index c244a7c01a..a5472654fa 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -1,21 +1,39 @@ -"""Cursor IDE integration.""" +"""Cursor IDE integration. -from ..base import MarkdownIntegration +Cursor Agent uses the ``.cursor/skills/speckit-/SKILL.md`` layout. +Commands are deprecated; ``--skills`` defaults to ``True``. +""" +from __future__ import annotations -class CursorAgentIntegration(MarkdownIntegration): +from ..base import IntegrationOption, SkillsIntegration + + +class CursorAgentIntegration(SkillsIntegration): key = "cursor-agent" config = { "name": "Cursor", "folder": ".cursor/", - "commands_subdir": "commands", + "commands_subdir": "skills", "install_url": None, "requires_cli": False, } registrar_config = { - "dir": ".cursor/commands", + "dir": ".cursor/skills", "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", + "extension": "/SKILL.md", } + context_file = ".cursor/rules/specify-rules.mdc" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (recommended for Cursor)", + ), + ] diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 71b7db1c98..3384fdc14f 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -1,11 +1,28 @@ """Tests for CursorAgentIntegration.""" -from .test_integration_base_markdown import MarkdownIntegrationTests +from .test_integration_base_skills import SkillsIntegrationTests -class TestCursorAgentIntegration(MarkdownIntegrationTests): +class TestCursorAgentIntegration(SkillsIntegrationTests): KEY = "cursor-agent" FOLDER = ".cursor/" - COMMANDS_SUBDIR = "commands" - REGISTRAR_DIR = ".cursor/commands" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".cursor/skills" CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" + + +class TestCursorAgentAutoPromote: + """--ai cursor-agent auto-promotes to integration path.""" + + def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path): + """--ai cursor-agent should work the same as --integration cursor-agent.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + target = tmp_path / "test-proj" + result = runner.invoke(app, ["init", str(target), "--ai", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"]) + + assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}" + assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists() + From 8bb08ae1a019ebfadfe6f364edb3aba20dd8339d Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 19:51:20 +0500 Subject: [PATCH 026/184] Add PR Bridge extension to community catalog (#2148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3 commands: generate PR descriptions, reviewer checklists, and change summaries - 1 hook: after_implement for auto-generating PR description - Closes the SDD workflow loop: specify → plan → tasks → implement → PR --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 0ca2da7663..492b5cc11e 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ The following community-contributed extensions are available in [`catalog.commun | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | 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-) | | 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: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 942a5605f7..1dbecc9d86 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1030,6 +1030,38 @@ "created_at": "2026-03-27T08:22:30Z", "updated_at": "2026-03-27T08:22:30Z" }, + "pr-bridge": { + "name": "PR Bridge", + "id": "pr-bridge", + "description": "Auto-generate pull request descriptions, checklists, and summaries from spec artifacts.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "pull-request", + "automation", + "traceability", + "workflow", + "review" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + }, "presetify": { "name": "Presetify", "id": "presetify", From d1b95c2f5904f27c92d553918c083141135cf0bb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:29:18 -0500 Subject: [PATCH 027/184] fix: bundled extensions should not have download URLs (#2155) * fix: bundled extensions should not have download URLs (#2151) - Remove selftest from default catalog (not a published extension) - Replace download_url with 'bundled: true' flag for git extension - Add bundled check in extension add flow with clear error message when bundled extension is missing from installed package - Add bundled check in download_extension() with specific error - Direct users to reinstall via uv with full GitHub URL - Add 3 regression tests for bundled extension handling * refactor: address review - move bundled check up-front, extract reinstall constant - Move bundled check before download_url inspection in download_extension() so bundled extensions can never be downloaded even with a URL present - Extract REINSTALL_COMMAND constant to avoid duplicated install strings * fix: allow bundled extensions with download_url to be updated Bundled extensions should only be blocked from download when they have no download_url. If a newer version is published to the catalog with a URL, users should be able to install it to get bug fixes. Add test for bundled-with-URL download path. --- extensions/catalog.json | 18 +----- src/specify_cli/__init__.py | 15 ++++- src/specify_cli/extensions.py | 10 +++ tests/test_extensions.py | 116 ++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 17 deletions(-) diff --git a/extensions/catalog.json b/extensions/catalog.json index a039883ba2..de9372e2bc 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-06T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", "extensions": { "git": { @@ -10,27 +10,13 @@ "description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection", "author": "spec-kit-core", "repository": "https://github.com/github/spec-kit", - "download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip", + "bundled": true, "tags": [ "git", "branching", "workflow", "core" ] - }, - "selftest": { - "name": "Spec Kit Self-Test Utility", - "id": "selftest", - "version": "1.0.0", - "description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.", - "author": "spec-kit-core", - "repository": "https://github.com/github/spec-kit", - "download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip", - "tags": [ - "testing", - "core", - "utility" - ] } } } \ No newline at end of file diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 7f343f7a14..e37c4b45f6 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -3007,7 +3007,7 @@ def extension_add( priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), ): """Install an extension.""" - from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError + from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND project_root = Path.cwd() @@ -3109,6 +3109,19 @@ def extension_add( manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority) if bundled_path is None: + # Bundled extensions without a download URL must come from the local package + if ext_info.get("bundled") and not ext_info.get("download_url"): + console.print( + f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) + # Enforce install_allowed policy if not ext_info.get("_install_allowed", True): catalog_name = ext_info.get("_catalog_name", "community") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index d03018b024..8f45c425ad 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -38,6 +38,8 @@ }) EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$") +REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git" + def _load_core_command_names() -> frozenset[str]: """Discover bundled core command names from the packaged templates. @@ -1870,6 +1872,14 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non if not ext_info: raise ExtensionError(f"Extension '{extension_id}' not found in catalog") + # Bundled extensions without a download URL must be installed locally + if ext_info.get("bundled") and not ext_info.get("download_url"): + raise ExtensionError( + f"Extension '{extension_id}' is bundled with spec-kit and has no download URL. " + f"It should be installed from the local package. " + f"Try reinstalling: {REINSTALL_COMMAND}" + ) + download_url = ext_info.get("download_url") if not download_url: raise ExtensionError(f"Extension '{extension_id}' has no download URL") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c5aed03dcf..a6ddff8e1a 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2995,6 +2995,122 @@ def mock_download(extension_id): f"but was called with '{download_called_with[0]}'" ) + def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path): + """extension add should give a clear error when a bundled extension is not found locally.""" + from typer.testing import CliRunner + from unittest.mock import patch, MagicMock + from specify_cli import app + + runner = CliRunner() + + # Create project structure + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + (project_dir / ".specify" / "extensions").mkdir(parents=True) + + # Mock catalog that returns a bundled extension without download_url + mock_catalog = MagicMock() + mock_catalog.get_extension_info.return_value = { + "id": "git", + "name": "Git Branching Workflow", + "version": "1.0.0", + "description": "Git branching extension", + "bundled": True, + "_install_allowed": True, + } + mock_catalog.search.return_value = [] + + with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \ + patch("specify_cli._locate_bundled_extension", return_value=None), \ + patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, + ["extension", "add", "git"], + catch_exceptions=True, + ) + + assert result.exit_code != 0 + assert "bundled with spec-kit" in result.output + assert "reinstall" in result.output.lower() + + +class TestDownloadExtensionBundled: + """Tests for download_extension handling of bundled extensions.""" + + def test_download_extension_raises_for_bundled(self, temp_dir): + """download_extension should raise a clear error for bundled extensions without a URL.""" + from unittest.mock import patch + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + bundled_ext_info = { + "name": "Git Branching Workflow", + "id": "git", + "version": "1.0.0", + "description": "Git workflow", + "bundled": True, + } + + with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info): + with pytest.raises(ExtensionError, match="bundled with spec-kit"): + catalog.download_extension("git") + + def test_download_extension_allows_bundled_with_url(self, temp_dir): + """download_extension should allow bundled extensions that have a download_url (newer version).""" + from unittest.mock import patch, MagicMock + import urllib.request + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + bundled_with_url = { + "name": "Git Branching Workflow", + "id": "git", + "version": "2.0.0", + "description": "Git workflow", + "bundled": True, + "download_url": "https://example.com/git-2.0.0.zip", + } + + mock_response = MagicMock() + mock_response.read.return_value = b"fake zip data" + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \ + patch.object(urllib.request, "urlopen", return_value=mock_response): + result = catalog.download_extension("git") + assert result.name == "git-2.0.0.zip" + + def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir): + """download_extension should raise 'no download URL' for non-bundled extensions without URL.""" + from unittest.mock import patch + + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + catalog = ExtensionCatalog(project_dir) + + non_bundled_ext_info = { + "name": "Some Extension", + "id": "some-ext", + "version": "1.0.0", + "description": "Test", + } + + with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info): + with pytest.raises(ExtensionError, match="has no download URL"): + catalog.download_extension("some-ext") + class TestExtensionUpdateCLI: """CLI integration tests for extension update command.""" From f43b85096c823d0ecb813ec8b6a48213e277d133 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 22:11:25 +0500 Subject: [PATCH 028/184] Add SpecTest extension to community catalog (#2159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SpecTest extension to community catalog Adds spec-kit-spectest: auto-generate test scaffolds from spec criteria. 4 commands: - /speckit.test.generate — generate framework-native test scaffolds - /speckit.test.coverage — map spec requirements to test coverage - /speckit.test.gaps — find untested requirements with suggestions - /speckit.test.plan — generate structured test plan documents 1 hook: after_implement (gap detection) Bridges the spec-to-test gap in the SDD workflow. * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix spectest created_at/updated_at to use current timestamp per Copilot review Set both to 2026-04-10T16:00:00Z instead of midnight. --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 35 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 492b5cc11e..c274df3dc0 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | +| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | | Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 1dbecc9d86..34f0443d70 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-10T12:34:56Z", + "updated_at": "2026-04-10T16:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1486,6 +1486,39 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "spectest": { + "name": "SpecTest", + "id": "spectest", + "description": "Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-spectest/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-spectest", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-spectest", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "testing", + "test-generation", + "coverage", + "quality", + "automation", + "traceability" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T16:00:00Z", + "updated_at": "2026-04-10T16:00:00Z" + }, "staff-review": { "name": "Staff Review Extension", "id": "staff-review", From 97ea7cf6a0d4b5353e597e54e7eb1fe870325924 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 22:20:55 +0500 Subject: [PATCH 029/184] Add CI Guard extension to community catalog (#2157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add CI Guard extension to community catalog Adds spec-kit-ci-guard: spec compliance gates for CI/CD pipelines. 5 commands: - /speckit.ci.check — run all compliance checks with pass/fail - /speckit.ci.report — generate requirement traceability matrix - /speckit.ci.gate — configure merge gate rules and thresholds - /speckit.ci.drift — detect bidirectional spec-to-code drift - /speckit.ci.badge — generate spec compliance badges 2 hooks: before_implement, after_implement Bridges the gap between SDD workflow and CI/CD enforcement. * Fix updated_at to monotonically increase per Copilot review Set to 2026-04-10T15:00:00Z (later than previous 2026-04-10T12:34:56Z). --- README.md | 1 + extensions/catalog.community.json | 35 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c274df3dc0..78f6d18121 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ The following community-contributed extensions are available in [`catalog.commun | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | +| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 34f0443d70..8cea7f83ba 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-10T16:00:00Z", + "updated_at": "2026-04-10T17:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -205,6 +205,39 @@ "created_at": "2026-03-29T00:00:00Z", "updated_at": "2026-03-29T00:00:00Z" }, + "ci-guard": { + "name": "CI Guard", + "id": "ci-guard", + "description": "Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-ci-guard", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-ci-guard", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 5, + "hooks": 2 + }, + "tags": [ + "ci-cd", + "compliance", + "governance", + "quality-gate", + "drift-detection", + "automation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T17:00:00Z", + "updated_at": "2026-04-10T17:00:00Z" + }, "checkpoint": { "name": "Checkpoint Extension", "id": "checkpoint", From 74e3f45aa91dd4e30e2765ef92811fbd3d213f9e Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 10 Apr 2026 23:16:26 +0500 Subject: [PATCH 030/184] Add Brownfield Bootstrap extension to community catalog (#2145) - 4 commands: scan, bootstrap, validate, migrate for existing codebases - 1 hook: after_init for auto-scanning project after spec-kit initialization - Addresses community request in issue #1436 (30+ reactions) --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 78f6d18121..0fb388a840 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ The following community-contributed extensions are available in [`catalog.commun | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | +| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) | | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | | CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 8cea7f83ba..c4312d5412 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -138,6 +138,38 @@ "created_at": "2026-04-08T00:00:00Z", "updated_at": "2026-04-08T00:00:00Z" }, + "brownfield": { + "name": "Brownfield Bootstrap", + "id": "brownfield", + "description": "Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-brownfield/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-brownfield", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-brownfield", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "brownfield", + "bootstrap", + "existing-project", + "migration", + "onboarding" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + }, "bugfix": { "name": "Bugfix Workflow", "id": "bugfix", From 43cb0fa7ab76463c6685d48d75b7f173cfd592e9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:18:06 -0500 Subject: [PATCH 031/184] feat: add bundled lean preset with minimal workflow commands (#2161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add bundled lean preset with minimal workflow commands Add a lean preset that overrides the 5 core workflow commands (specify, plan, tasks, implement, constitution) with minimal prompts that produce exactly one artifact each — no extension hooks, no scripts, no git branching, no templates. Bundled preset infrastructure: - Add _locate_bundled_preset() mirroring _locate_bundled_extension() - Update 'specify init --preset' to try bundled -> catalog fallback - Update 'specify preset add' to try bundled -> catalog fallback - Add bundled guard in download_pack() for presets without download URLs - Add lean to presets/catalog.json with 'bundled: true' marker - Add lean to pyproject.toml force-include for wheel packaging - Align error messages with bundled extension error pattern Tests: 15 new tests (TestLeanPreset + TestBundledPresetLocator) * refactor: address review — clean up unused imports, strengthen test assertions - Remove unused MagicMock import and cache_dir setup in download test - Assert 'bundled' and 'reinstall' in CLI error output (not just exit code) - Mock catalog in missing-locally test for deterministic bundled error path - Fix test versions to satisfy updated speckit_version >=0.6.0 requirement * refactor: address review — fix constitution paths, add REINSTALL_COMMAND to presets.py - Fix constitution path to .specify/memory/constitution.md in plan, tasks, implement commands (matching core command convention) - Include REINSTALL_COMMAND in download_pack() bundled guard for consistent recovery instructions across bundled extensions and presets * refactor: address review — explicit feature_directory paths, ZIP cleanup in finally - Prefix spec.md/plan.md/tasks.md with / in plan, tasks, and implement commands so the agent doesn't operate on repo root by mistake - Move ZIP unlink into finally block in init --preset path so cleanup runs even when install_from_zip raises (matching preset_add pattern) * refactor: address review — replace Unicode em dashes with ASCII, fix grammar - Replace all Unicode em dashes with ASCII hyphens in preset.yml and catalog.json to avoid decode errors on non-UTF-8 environments - Fix grammar: 'store it in tasks.md' -> 'store them in tasks.md' * refactor: address review - align task format between tasks and implement - Remove undefined [P] marker from implement (lean uses sequential execution) - Clarify checkbox update: 'change - [ ] to - [x]' instead of ambiguous '[X]' - Simplify implement to execute tasks in order without parallel complexity * refactor: address review - parse frontmatter instead of raw substring search - Use CommandRegistrar.parse_frontmatter() to check for scripts/agent_scripts keys in YAML frontmatter instead of brittle 'scripts:' substring search --- presets/catalog.json | 20 +- presets/lean/commands/speckit.constitution.md | 15 ++ presets/lean/commands/speckit.implement.md | 22 +++ presets/lean/commands/speckit.plan.md | 19 ++ presets/lean/commands/speckit.specify.md | 23 +++ presets/lean/commands/speckit.tasks.md | 19 ++ presets/lean/preset.yml | 50 +++++ pyproject.toml | 2 + src/specify_cli/__init__.py | 130 +++++++++---- src/specify_cli/presets.py | 10 + tests/test_presets.py | 179 ++++++++++++++++++ 11 files changed, 454 insertions(+), 35 deletions(-) create mode 100644 presets/lean/commands/speckit.constitution.md create mode 100644 presets/lean/commands/speckit.implement.md create mode 100644 presets/lean/commands/speckit.plan.md create mode 100644 presets/lean/commands/speckit.specify.md create mode 100644 presets/lean/commands/speckit.tasks.md create mode 100644 presets/lean/preset.yml diff --git a/presets/catalog.json b/presets/catalog.json index ca40f85280..5650092baf 100644 --- a/presets/catalog.json +++ b/presets/catalog.json @@ -1,6 +1,22 @@ { "schema_version": "1.0", - "updated_at": "2026-03-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json", - "presets": {} + "presets": { + "lean": { + "name": "Lean Workflow", + "id": "lean", + "version": "1.0.0", + "description": "Minimal core workflow commands - just the prompt, just the artifact", + "author": "github", + "repository": "https://github.com/github/spec-kit", + "bundled": true, + "tags": [ + "lean", + "minimal", + "workflow", + "core" + ] + } + } } diff --git a/presets/lean/commands/speckit.constitution.md b/presets/lean/commands/speckit.constitution.md new file mode 100644 index 0000000000..920337003e --- /dev/null +++ b/presets/lean/commands/speckit.constitution.md @@ -0,0 +1,15 @@ +--- +description: Create or update the project constitution. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Create or update the project constitution and store it in `.specify/memory/constitution.md`. + - Project name, guiding principles, non-negotiable rules + - Derive from user input and existing repo context (README, docs) diff --git a/presets/lean/commands/speckit.implement.md b/presets/lean/commands/speckit.implement.md new file mode 100644 index 0000000000..fc68a1f8b1 --- /dev/null +++ b/presets/lean/commands/speckit.implement.md @@ -0,0 +1,22 @@ +--- +description: Execute the implementation plan by processing all tasks in tasks.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Read `.specify/feature.json` to get the feature directory path. + +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md` and `/plan.md` and `/tasks.md`. + +3. **Execute tasks** in order: + - Complete each task before moving to the next + - Mark completed tasks by changing `- [ ]` to `- [x]` in `/tasks.md` + - Halt on failure and report the issue + +4. **Validate**: Verify all tasks are completed and the implementation matches the spec. diff --git a/presets/lean/commands/speckit.plan.md b/presets/lean/commands/speckit.plan.md new file mode 100644 index 0000000000..9fbbe4c371 --- /dev/null +++ b/presets/lean/commands/speckit.plan.md @@ -0,0 +1,19 @@ +--- +description: Create a plan and store it in plan.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Read `.specify/feature.json` to get the feature directory path. + +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md`. + +3. Create an implementation plan and store it in `/plan.md`. + - Technical context: tech stack, dependencies, project structure + - Design decisions, architecture, file structure diff --git a/presets/lean/commands/speckit.specify.md b/presets/lean/commands/speckit.specify.md new file mode 100644 index 0000000000..c15353557a --- /dev/null +++ b/presets/lean/commands/speckit.specify.md @@ -0,0 +1,23 @@ +--- +description: Create a specification and store it in spec.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. **Ask the user** for the feature directory path (e.g., `specs/my-feature`). Do not proceed until provided. + +2. Create the directory and write `.specify/feature.json`: + ```json + { "feature_directory": "" } + ``` + +3. Create a specification from the user input and store it in `/spec.md`. + - Overview, functional requirements, user scenarios, success criteria + - Every requirement must be testable + - Make informed defaults for unspecified details diff --git a/presets/lean/commands/speckit.tasks.md b/presets/lean/commands/speckit.tasks.md new file mode 100644 index 0000000000..724a7b8400 --- /dev/null +++ b/presets/lean/commands/speckit.tasks.md @@ -0,0 +1,19 @@ +--- +description: Create the tasks needed for implementation and store them in tasks.md. +--- + +## User Input + +```text +$ARGUMENTS +``` + +## Outline + +1. Read `.specify/feature.json` to get the feature directory path. + +2. **Load context**: `.specify/memory/constitution.md` and `/spec.md` and `/plan.md`. + +3. Create dependency-ordered implementation tasks and store them in `/tasks.md`. + - Every task uses checklist format: `- [ ] [TaskID] Description with file path` + - Organized by phase: setup, foundational, user stories in priority order, polish diff --git a/presets/lean/preset.yml b/presets/lean/preset.yml new file mode 100644 index 0000000000..eae84928c8 --- /dev/null +++ b/presets/lean/preset.yml @@ -0,0 +1,50 @@ +schema_version: "1.0" + +preset: + id: "lean" + name: "Lean Workflow" + version: "1.0.0" + description: "Minimal core workflow commands - just the prompt, just the artifact" + author: "github" + repository: "https://github.com/github/spec-kit" + license: "MIT" + +requires: + speckit_version: ">=0.6.0" + +provides: + templates: + - type: "command" + name: "speckit.specify" + file: "commands/speckit.specify.md" + description: "Lean specify - create spec.md from a feature description" + replaces: "speckit.specify" + + - type: "command" + name: "speckit.plan" + file: "commands/speckit.plan.md" + description: "Lean plan - create plan.md from the spec" + replaces: "speckit.plan" + + - type: "command" + name: "speckit.tasks" + file: "commands/speckit.tasks.md" + description: "Lean tasks - create tasks.md from plan and spec" + replaces: "speckit.tasks" + + - type: "command" + name: "speckit.implement" + file: "commands/speckit.implement.md" + description: "Lean implement - execute tasks from tasks.md" + replaces: "speckit.implement" + + - type: "command" + name: "speckit.constitution" + file: "commands/speckit.constitution.md" + description: "Lean constitution - create or update project constitution" + replaces: "speckit.constitution" + +tags: + - "lean" + - "minimal" + - "workflow" diff --git a/pyproject.toml b/pyproject.toml index e43f812724..5c4e464c9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ packages = ["src/specify_cli"] "scripts/powershell" = "specify_cli/core_pack/scripts/powershell" # Bundled extensions (installable via `specify extension add `) "extensions/git" = "specify_cli/core_pack/extensions/git" +# Bundled presets (installable via `specify preset add ` or `specify init --preset `) +"presets/lean" = "specify_cli/core_pack/presets/lean" [project.optional-dependencies] test = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e37c4b45f6..0bbf42ad5a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -621,6 +621,31 @@ def _locate_bundled_extension(extension_id: str) -> Path | None: return None +def _locate_bundled_preset(preset_id: str) -> Path | None: + """Return the path to a bundled preset, or None. + + Checks the wheel's core_pack first, then falls back to the + source-checkout ``presets//`` directory. + """ + import re as _re + if not _re.match(r'^[a-z0-9-]+$', preset_id): + return None + + core = _locate_core_pack() + if core is not None: + candidate = core / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate + + # Source-checkout / editable install: look relative to repo root + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "presets" / preset_id + if (candidate / "preset.yml").is_file(): + return candidate + + return None + + def _install_shared_infra( project_path: Path, script_type: str, @@ -1266,27 +1291,44 @@ def init( preset_manager = PresetManager(project_path) speckit_ver = get_speckit_version() - # Try local directory first, then catalog + # Try local directory first, then bundled, then catalog local_path = Path(preset).resolve() if local_path.is_dir() and (local_path / "preset.yml").exists(): preset_manager.install_from_directory(local_path, speckit_ver) else: - preset_catalog = PresetCatalog(project_path) - pack_info = preset_catalog.get_pack_info(preset) - if not pack_info: - console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") + bundled_path = _locate_bundled_preset(preset) + if bundled_path: + preset_manager.install_from_directory(bundled_path, speckit_ver) else: - try: - zip_path = preset_catalog.download_pack(preset) - preset_manager.install_from_zip(zip_path, speckit_ver) - # Clean up downloaded ZIP to avoid cache accumulation + preset_catalog = PresetCatalog(project_path) + pack_info = preset_catalog.get_pack_info(preset) + if not pack_info: + console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.") + elif pack_info.get("bundled") and not pack_info.get("download_url"): + from .extensions import REINSTALL_COMMAND + console.print( + f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "This usually means the spec-kit installation is incomplete or corrupted." + ) + console.print(f"Try reinstalling: {REINSTALL_COMMAND}") + else: + zip_path = None try: - zip_path.unlink(missing_ok=True) - except OSError: - # Best-effort cleanup; failure to delete is non-fatal - pass - except PresetError as preset_err: - console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") + zip_path = preset_catalog.download_pack(preset) + preset_manager.install_from_zip(zip_path, speckit_ver) + except PresetError as preset_err: + console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}") + finally: + if zip_path is not None: + # Clean up downloaded ZIP to avoid cache accumulation + try: + zip_path.unlink(missing_ok=True) + except OSError: + # Best-effort cleanup; failure to delete is non-fatal + pass except Exception as preset_err: console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}") @@ -2140,28 +2182,50 @@ def preset_add( console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") elif pack_id: - catalog = PresetCatalog(project_root) - pack_info = catalog.get_pack_info(pack_id) + # Try bundled preset first, then catalog + bundled_path = _locate_bundled_preset(pack_id) + if bundled_path: + console.print(f"Installing bundled preset [cyan]{pack_id}[/cyan]...") + manifest = manager.install_from_directory(bundled_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + else: + catalog = PresetCatalog(project_root) + pack_info = catalog.get_pack_info(pack_id) - if not pack_info: - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog") - raise typer.Exit(1) + if not pack_info: + console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog") + raise typer.Exit(1) - if not pack_info.get("_install_allowed", True): - catalog_name = pack_info.get("_catalog_name", "unknown") - console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") - console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") - raise typer.Exit(1) + # Bundled presets should have been caught above; if we reach + # here the bundled files are missing from the installation. + if pack_info.get("bundled") and not pack_info.get("download_url"): + from .extensions import REINSTALL_COMMAND + console.print( + f"[red]Error:[/red] Preset '{pack_id}' is bundled with spec-kit " + f"but could not be found in the installed package." + ) + console.print( + "\nThis usually means the spec-kit installation is incomplete or corrupted." + ) + console.print("Try reinstalling spec-kit:") + console.print(f" {REINSTALL_COMMAND}") + raise typer.Exit(1) - console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...") + if not pack_info.get("_install_allowed", True): + catalog_name = pack_info.get("_catalog_name", "unknown") + console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") + console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") + raise typer.Exit(1) - try: - zip_path = catalog.download_pack(pack_id) - manifest = manager.install_from_zip(zip_path, speckit_version, priority) - console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - finally: - if 'zip_path' in locals() and zip_path.exists(): - zip_path.unlink(missing_ok=True) + console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...") + + try: + zip_path = catalog.download_pack(pack_id) + manifest = manager.install_from_zip(zip_path, speckit_version, priority) + console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") + finally: + if 'zip_path' in locals() and zip_path.exists(): + zip_path.unlink(missing_ok=True) else: console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path") raise typer.Exit(1) diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 137d1d22a8..3a0f469a77 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -1587,6 +1587,16 @@ def download_pack( f"Preset '{pack_id}' not found in catalog" ) + # Bundled presets without a download URL must be installed locally + if pack_info.get("bundled") and not pack_info.get("download_url"): + from .extensions import REINSTALL_COMMAND + raise PresetError( + f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. " + f"It should be installed from the local package. " + f"Use 'specify preset add {pack_id}' to install from the bundled package, " + f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}" + ) + if not pack_info.get("_install_allowed", True): catalog_name = pack_info.get("_catalog_name", "unknown") raise PresetError( diff --git a/tests/test_presets.py b/tests/test_presets.py index d22264f806..95af7a900f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2865,3 +2865,182 @@ def test_disable_corrupted_registry_entry(self, project_dir, pack_dir): assert result.exit_code == 1 assert "corrupted state" in result.output.lower() + + +# ===== Lean Preset Tests ===== + + +LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean" + +LEAN_COMMAND_NAMES = [ + "speckit.specify", + "speckit.plan", + "speckit.tasks", + "speckit.implement", + "speckit.constitution", +] + + +class TestLeanPreset: + """Tests for the lean preset that ships with the repo.""" + + def test_lean_preset_exists(self): + """Verify the lean preset directory and manifest exist.""" + assert LEAN_PRESET_DIR.exists() + assert (LEAN_PRESET_DIR / "preset.yml").exists() + + def test_lean_manifest_valid(self): + """Verify the lean preset manifest is valid.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + assert manifest.id == "lean" + assert manifest.name == "Lean Workflow" + assert manifest.version == "1.0.0" + assert len(manifest.templates) == 5 # 5 commands + + def test_lean_provides_core_workflow_commands(self): + """Verify the lean preset provides overrides for core workflow commands.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + provided_names = {t["name"] for t in manifest.templates} + for name in LEAN_COMMAND_NAMES: + assert name in provided_names, f"Lean preset missing command: {name}" + + def test_lean_command_files_exist(self): + """Verify that all declared command files actually exist on disk.""" + manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml") + for tmpl in manifest.templates: + tmpl_path = LEAN_PRESET_DIR / tmpl["file"] + assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" + + def test_lean_commands_have_no_scripts(self): + """Verify lean commands have no scripts or agent_scripts in frontmatter.""" + from specify_cli.agents import CommandRegistrar + + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + frontmatter, _ = CommandRegistrar.parse_frontmatter(content) + assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" + assert "agent_scripts" not in frontmatter, f"{name} should not have agent_scripts in frontmatter" + + def test_lean_commands_have_no_hooks(self): + """Verify lean commands do not contain extension hook boilerplate.""" + for name in LEAN_COMMAND_NAMES: + cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md" + content = cmd_path.read_text() + assert "hooks." not in content, f"{name} should not reference extension hooks" + assert "extensions.yml" not in content, f"{name} should not reference extensions.yml" + + def test_install_lean_preset(self, project_dir): + """Test installing the lean preset from its directory.""" + manager = PresetManager(project_dir) + manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") + assert manifest.id == "lean" + assert manager.registry.is_installed("lean") + + def test_lean_overrides_commands(self, project_dir): + """Test that lean preset overrides are resolved correctly.""" + manager = PresetManager(project_dir) + manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0") + + resolver = PresetResolver(project_dir) + for name in LEAN_COMMAND_NAMES: + result = resolver.resolve(name, template_type="command") + assert result is not None, f"Lean override for {name} not resolved" + + +# ===== Bundled Preset Locator Tests ===== + + +class TestBundledPresetLocator: + """Tests for _locate_bundled_preset discovery function.""" + + def test_locate_bundled_lean_preset(self): + """_locate_bundled_preset finds the lean preset.""" + from specify_cli import _locate_bundled_preset + + path = _locate_bundled_preset("lean") + assert path is not None + assert (path / "preset.yml").is_file() + + def test_locate_bundled_preset_not_found(self): + """_locate_bundled_preset returns None for nonexistent preset.""" + from specify_cli import _locate_bundled_preset + + path = _locate_bundled_preset("nonexistent-preset") + assert path is None + + def test_locate_bundled_preset_rejects_invalid_id(self): + """_locate_bundled_preset rejects IDs with invalid characters.""" + from specify_cli import _locate_bundled_preset + + assert _locate_bundled_preset("../escape") is None + assert _locate_bundled_preset("UPPERCASE") is None + assert _locate_bundled_preset("has spaces") is None + + def test_bundled_preset_add_via_cli(self, project_dir): + """Test that 'specify preset add lean' installs the bundled preset.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli.get_speckit_version", return_value="0.6.0"): + result = runner.invoke(app, ["preset", "add", "lean"]) + + assert result.exit_code == 0, result.output + assert "Lean Workflow" in result.output + assert "installed" in result.output.lower() + + def test_bundled_preset_in_catalog(self): + """Verify the lean preset is listed in catalog.json with bundled marker.""" + catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json" + catalog = json.loads(catalog_path.read_text()) + assert "lean" in catalog["presets"] + assert catalog["presets"]["lean"]["bundled"] is True + assert "download_url" not in catalog["presets"]["lean"] + + def test_bundled_preset_download_raises_error(self, project_dir): + """download_pack raises PresetError for bundled presets without download_url.""" + catalog = PresetCatalog(project_dir) + + catalog_data = { + "test-bundled": { + "name": "Test Bundled", + "version": "1.0.0", + "bundled": True, + } + } + from unittest.mock import patch + with patch.object(catalog, "_get_merged_packs", return_value=catalog_data): + with pytest.raises(PresetError, match="bundled with spec-kit"): + catalog.download_pack("test-bundled") + + def test_bundled_preset_missing_locally_cli_error(self, project_dir): + """CLI shows clear error when bundled preset cannot be found locally.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + runner = CliRunner() + # Patch _locate_bundled_preset to return None (simulating missing files) + # and mock the catalog to return a bundled entry for "lean" + fake_pack_info = { + "id": "lean", + "name": "Lean Workflow", + "version": "1.0.0", + "bundled": True, + "_install_allowed": True, + } + with patch.object(Path, "cwd", return_value=project_dir), \ + patch("specify_cli._locate_bundled_preset", return_value=None), \ + patch("specify_cli.presets.PresetCatalog") as MockCatalog: + MockCatalog.return_value.get_pack_info.return_value = fake_pack_info + result = runner.invoke(app, ["preset", "add", "lean"]) + + # Should fail with a helpful error explaining this is a bundled preset + # and suggesting how to recover. + assert result.exit_code == 1 + output = strip_ansi(result.output).lower() + assert "bundled" in output, result.output + assert "reinstall" in output, result.output From 1cb794e516216f5046a84013e1c480b3aba5b61c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:22:42 -0500 Subject: [PATCH 032/184] chore: release 0.6.1, begin 0.6.2.dev0 development (#2162) * chore: bump version to 0.6.1 * chore: begin 0.6.2.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 16 ++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 139221d075..6fd932a4b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ +## [0.6.1] - 2026-04-10 + +### Changed + +- feat: add bundled lean preset with minimal workflow commands (#2161) +- Add Brownfield Bootstrap extension to community catalog (#2145) +- Add CI Guard extension to community catalog (#2157) +- Add SpecTest extension to community catalog (#2159) +- fix: bundled extensions should not have download URLs (#2155) +- Add PR Bridge extension to community catalog (#2148) +- feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156) +- Add TinySpec extension to community catalog (#2147) +- chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146) +- Add Status Report extension to community catalog (#2123) +- chore: release 0.6.0, begin 0.6.1.dev0 development (#2144) + ## [0.6.0] - 2026-04-09 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 5c4e464c9b..7fec6cc6ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.6.1.dev0" +version = "0.6.2.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From cdbea09e1a00b0899148e82a4c366bed7482065f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:57:47 -0500 Subject: [PATCH 033/184] fix: skip docs deployment workflow on forks (#2171) Add repository check to build and deploy jobs so they skip with success on forks, avoiding failed Pages deployments for contributors. --- .github/workflows/docs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 847f564557..5f1f97dc77 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,6 +26,7 @@ concurrency: jobs: # Build job build: + if: github.repository == 'github/spec-kit' runs-on: ubuntu-latest steps: - name: Checkout @@ -56,6 +57,7 @@ jobs: # Deploy job deploy: + if: github.repository == 'github/spec-kit' environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} From 52ed84d7234d5eb1312a01ca19abff407b8fa200 Mon Sep 17 00:00:00 2001 From: Ben Lawson Date: Mon, 13 Apr 2026 08:42:08 -0400 Subject: [PATCH 034/184] Update ralph extension to v1.0.1 in community catalog (#2192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extension ID: ralph - Version: 1.0.0 → 1.0.1 - Author: Rubiss - Changes: Fixed bash task count bug, removed hardcoded model, added regression tests, CI workflow, and release pipeline. - Release: https://github.com/Rubiss/spec-kit-ralph/releases/tag/v1.0.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index c4312d5412..ec7ad87c55 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-10T17:00:00Z", + "updated_at": "2026-04-12T19:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1225,8 +1225,8 @@ "id": "ralph", "description": "Autonomous implementation loop using AI agent CLI.", "author": "Rubiss", - "version": "1.0.0", - "download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.0.zip", + "version": "1.0.1", + "download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.1.zip", "repository": "https://github.com/Rubiss/spec-kit-ralph", "homepage": "https://github.com/Rubiss/spec-kit-ralph", "documentation": "https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md", @@ -1259,7 +1259,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-09T00:00:00Z", - "updated_at": "2026-03-09T00:00:00Z" + "updated_at": "2026-04-12T19:00:00Z" }, "reconcile": { "name": "Reconcile Extension", From b67b2856b15aabdd0e2764079d4b1e45bc256923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Furkan=20K=C3=B6yk=C4=B1ran?= Date: Mon, 13 Apr 2026 15:55:44 +0300 Subject: [PATCH 035/184] feat(agents): add Goose AI agent support (#2015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(integrations): add YamlIntegration base class for YAML recipe agents Signed-off-by: Furkan Köykıran * feat(integrations): add Goose integration subpackage with YAML recipe support Signed-off-by: Furkan Köykıran * feat(integrations): register GooseIntegration in the integration registry Signed-off-by: Furkan Köykıran * feat(agents): add YAML format support to CommandRegistrar for extension/preset commands Signed-off-by: Furkan Köykıran * feat(scripts): add goose agent type to bash update-agent-context script Signed-off-by: Furkan Köykıran * feat(scripts): add goose agent type to PowerShell update-agent-context script Signed-off-by: Furkan Köykıran * docs(agents): add Goose to supported agents table and integration notes Signed-off-by: Furkan Köykıran * docs(readme): add Goose to supported agents table Signed-off-by: Furkan Köykıran * test(integrations): add YamlIntegrationTests base mixin for YAML agent testing Signed-off-by: Furkan Köykıran * test(integrations): add Goose integration tests Signed-off-by: Furkan Köykıran * test(consistency): add Goose consistency checks for config, registrar, and scripts Signed-off-by: Furkan Köykıran * docs(agents): move Goose to YAML Format section in Command File Formats Goose uses YAML recipes, not Markdown. Remove it from the Markdown Format list and add a dedicated YAML Format subsection with a representative recipe example showing prompt: | and {{args}} placeholders. * refactor(agents): delegate render_yaml_command to YamlIntegration Remove the duplicate header dict, yaml.safe_dump call, body indentation, and _human_title logic from CommandRegistrar.render_yaml_command(). Delegate to YamlIntegration._render_yaml() and _human_title() so YAML recipe output stays consistent across the init-time generation and command-registration code paths. * fix(agents): guard alias output path against directory traversal Validate that alias_file resolves within commands_dir before writing. Uses the same resolve().relative_to() pattern already established in extensions.py for ZIP path containment checks. * docs(agents): add Goose to Multi-Agent Support comment list in update-agent-context.sh * fix(agents): add goose to print_summary Usage line in bash context script The print_summary() function listed all supported agents in its Usage output but omitted goose, making it inconsistent with the header docs and the error message in update_specific_agent(). Signed-off-by: Furkan Köykıran * fix(agents): add goose to Print-Summary Usage line in PowerShell context script The Print-Summary function listed all supported agents in its Usage output but omitted goose, making it inconsistent with the ValidateSet and the header documentation. Signed-off-by: Furkan Köykıran * fix(agents): normalize description and title types in YamlIntegration.setup() YAML frontmatter can contain non-string types (null, list, int). Add isinstance checks matching TomlIntegration._extract_description() to ensure Goose recipes always receive valid string fields. Signed-off-by: Furkan Köykıran * fix(agents): validate shared script exists before exec in Goose bash wrapper Add Forge-style check that the shared update-agent-context.sh is present and executable, producing a clear error instead of a cryptic shell exec failure when the shared script is missing. Signed-off-by: Furkan Köykıran * fix(agents): validate shared script exists before invoke in Goose PowerShell wrapper Add Forge-style Test-Path check that the shared update-agent-context.ps1 exists, producing a clear error instead of a cryptic PowerShell failure when the shared script is missing. Signed-off-by: Furkan Köykıran * fix(agents): normalize title and description types in render_yaml_command() Extension/preset frontmatter can contain non-string types. Add isinstance checks matching the normalization in YamlIntegration.setup() so both code paths produce valid Goose recipe fields. Signed-off-by: Furkan Köykıran * fix(agents): replace $ARGUMENTS with arg_placeholder in process_template() Signed-off-by: Furkan Köykıran * test(agents): assert $ARGUMENTS absent from generated YAML recipes Signed-off-by: Furkan Köykıran * test(agents): assert $ARGUMENTS absent from generated TOML commands Signed-off-by: Furkan Köykıran * fix(tests): rewrite docstring to avoid embedded triple-quote in TOML test Signed-off-by: Furkan Köykıran --------- Signed-off-by: Furkan Köykıran --- AGENTS.md | 69 ++- README.md | 3 +- scripts/bash/update-agent-context.sh | 15 +- scripts/powershell/update-agent-context.ps1 | 12 +- src/specify_cli/agents.py | 185 +++++-- src/specify_cli/integrations/__init__.py | 3 + src/specify_cli/integrations/base.py | 242 ++++++++- .../integrations/goose/__init__.py | 21 + .../goose/scripts/update-context.ps1 | 33 ++ .../goose/scripts/update-context.sh | 38 ++ .../test_integration_base_toml.py | 182 +++++-- .../test_integration_base_yaml.py | 459 ++++++++++++++++++ tests/integrations/test_integration_goose.py | 11 + tests/test_agent_config_consistency.py | 97 +++- 14 files changed, 1228 insertions(+), 142 deletions(-) create mode 100644 src/specify_cli/integrations/goose/__init__.py create mode 100644 src/specify_cli/integrations/goose/scripts/update-context.ps1 create mode 100755 src/specify_cli/integrations/goose/scripts/update-context.sh create mode 100644 tests/integrations/test_integration_base_yaml.py create mode 100644 tests/integrations/test_integration_goose.py diff --git a/AGENTS.md b/AGENTS.md index 27472ebec9..2b076dc384 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Each AI agent is a self-contained **integration subpackage** under `src/specify_ ``` src/specify_cli/integrations/ ├── __init__.py # INTEGRATION_REGISTRY + _register_builtins() -├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, SkillsIntegration +├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration ├── manifest.py # IntegrationManifest (file tracking) ├── claude/ # Example: SkillsIntegration subclass │ ├── __init__.py # ClaudeIntegration class @@ -48,6 +48,7 @@ The registry is the **single source of truth for Python integration metadata**. |---|---| | Standard markdown commands (`.md`) | `MarkdownIntegration` | | TOML-format commands (`.toml`) | `TomlIntegration` | +| YAML recipe files (`.yaml`) | `YamlIntegration` | | Skill directories (`speckit-/SKILL.md`) | `SkillsIntegration` | | Fully custom output (companion files, settings merge, etc.) | `IntegrationBase` directly | @@ -343,16 +344,82 @@ Command content with {SCRIPT} and {{args}} placeholders. """ ``` +### YAML Format + +Used by: Goose + +```yaml +version: 1.0.0 +title: "Command Title" +description: "Command description" +author: + contact: spec-kit +extensions: + - type: builtin + name: developer +activities: + - Spec-Driven Development +prompt: | + Command content with {SCRIPT} and {{args}} placeholders. +``` + ## Argument Patterns Different agents use different argument placeholders. The placeholder used in command files is always taken from `registrar_config["args"]` for each integration — check there first when in doubt: - **Markdown/prompt-based**: `$ARGUMENTS` (default for most markdown agents) - **TOML-based**: `{{args}}` (e.g., Gemini) +- **YAML-based**: `{{args}}` (e.g., Goose) - **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`) - **Script placeholders**: `{SCRIPT}` (replaced with actual script path) - **Agent placeholders**: `__AGENT__` (replaced with agent name) +## Special Processing Requirements + +Some agents require custom processing beyond the standard template transformations: + +### Copilot Integration + +GitHub Copilot has unique requirements: +- Commands use `.agent.md` extension (not `.md`) +- Each command gets a companion `.prompt.md` file in `.github/prompts/` +- Installs `.vscode/settings.json` with prompt file recommendations +- Context file lives at `.github/copilot-instructions.md` + +Implementation: Extends `IntegrationBase` with custom `setup()` method that: +1. Processes templates with `process_template()` +2. Generates companion `.prompt.md` files +3. Merges VS Code settings + +### Forge Integration + +Forge has special frontmatter and argument requirements: +- Uses `{{parameters}}` instead of `$ARGUMENTS` +- Strips `handoffs` frontmatter key (Forge-specific collaboration feature) +- Injects `name` field into frontmatter when missing + +Implementation: Extends `MarkdownIntegration` with custom `setup()` method that: +1. Inherits standard template processing from `MarkdownIntegration` +2. Adds extra `$ARGUMENTS` → `{{parameters}}` replacement after template processing +3. Applies Forge-specific transformations via `_apply_forge_transformations()` +4. Strips `handoffs` frontmatter key +5. Injects missing `name` fields +6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` and lists `forge` in their usage/help text + +### Goose Integration + +Goose is a YAML-format agent using Block's recipe system: +- Uses `.goose/recipes/` directory for YAML recipe files +- Uses `{{args}}` argument placeholder +- Produces YAML with `prompt: |` block scalar for command content + +Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): +1. Processes templates through the standard placeholder pipeline +2. Extracts title and description from frontmatter +3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt) +4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping +5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge) + ## Common Pitfalls 1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint. diff --git a/README.md b/README.md index 0fb388a840..e366ad5b13 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ Community projects that extend, visualize, or build on Spec Kit: | [Cursor](https://cursor.sh/) | ✅ | | | [Forge](https://forgecode.dev/) | ✅ | CLI tool: `forge` | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | | +| [Goose](https://block.github.io/goose/) | ✅ | Uses YAML recipe format in `.goose/recipes/` with slash command support | | [GitHub Copilot](https://code.visualstudio.com/) | ✅ | | | [IBM Bob](https://www.ibm.com/products/bob) | ✅ | IDE-based agent with slash command support | | [Jules](https://jules.google.com/) | ✅ | | @@ -654,7 +655,7 @@ specify init . --force --ai claude specify init --here --force --ai claude ``` -The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: +The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash specify init --ai claude --ignore-agent-tools diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh index fce379b34d..2f71bb893c 100644 --- a/scripts/bash/update-agent-context.sh +++ b/scripts/bash/update-agent-context.sh @@ -30,12 +30,12 @@ # # 5. Multi-Agent Support # - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Antigravity or Generic +# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Goose, Antigravity or Generic # - Can update single agents or all existing agent files # - Creates default Claude file if no agent files exist # # Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic +# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic # Leave empty to update all existing agent files set -e @@ -74,7 +74,7 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" QODER_FILE="$REPO_ROOT/QODER.md" -# Amp, Kiro CLI, IBM Bob, Pi, and Forge all share AGENTS.md — use AGENTS_FILE to avoid +# Amp, Kiro CLI, IBM Bob, Pi, Forge, and Goose all share AGENTS.md — use AGENTS_FILE to avoid # updating the same file multiple times. AMP_FILE="$AGENTS_FILE" SHAI_FILE="$REPO_ROOT/SHAI.md" @@ -710,12 +710,15 @@ update_specific_agent() { forge) update_agent_file "$AGENTS_FILE" "Forge" || return 1 ;; + goose) + update_agent_file "$AGENTS_FILE" "Goose" || return 1 + ;; generic) log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." ;; *) log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic" + log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic" exit 1 ;; esac @@ -759,7 +762,7 @@ update_all_existing_agents() { _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false - _update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge" || _all_ok=false + _update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose" || _all_ok=false _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false _update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false @@ -800,7 +803,7 @@ print_summary() { fi echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]" + log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]" } #============================================================================== diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 index 12caa306da..3ee45d383c 100644 --- a/scripts/powershell/update-agent-context.ps1 +++ b/scripts/powershell/update-agent-context.ps1 @@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh: 2. Plan Data Extraction 3. Agent File Management (create from template or update existing) 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, generic) + 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, goose, generic) .PARAMETER AgentType Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). @@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1 #> param( [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','generic')] + [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','goose','generic')] [string]$AgentType ) @@ -68,6 +68,7 @@ $KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' $TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.md' $IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md' $FORGE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' +$GOOSE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' $TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' @@ -417,8 +418,9 @@ function Update-SpecificAgent { 'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' } 'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' } 'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' } + 'goose' { Update-AgentFile -TargetFile $GOOSE_FILE -AgentName 'Goose' } 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic'; return $false } + default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic'; return $false } } } @@ -460,7 +462,7 @@ function Update-AllExistingAgents { if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false } if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false } if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge')) { $ok = $false } + if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose')) { $ok = $false } if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false } if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false } if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false } @@ -490,7 +492,7 @@ function Print-Summary { if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]' + Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]' } function Main { diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index ec7af88768..e978d0136e 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -18,6 +18,7 @@ def _build_agent_configs() -> dict[str, Any]: """Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY.""" from specify_cli.integrations import INTEGRATION_REGISTRY + configs: dict[str, dict[str, Any]] = {} for key, integration in INTEGRATION_REGISTRY.items(): if key == "generic": @@ -75,7 +76,7 @@ def parse_frontmatter(content: str) -> tuple[dict, str]: return {}, content frontmatter_str = content[3:end_marker].strip() - body = content[end_marker + 3:].strip() + body = content[end_marker + 3 :].strip() try: frontmatter = yaml.safe_load(frontmatter_str) or {} @@ -100,7 +101,9 @@ def render_frontmatter(fm: dict) -> str: if not fm: return "" - yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True) + yaml_str = yaml.dump( + fm, default_flow_style=False, sort_keys=False, allow_unicode=True + ) return f"---\n{yaml_str}---\n" def _adjust_script_paths(self, frontmatter: dict) -> dict: @@ -146,16 +149,16 @@ def rewrite_project_relative_paths(text: str) -> str: # ".specify/extensions//scripts/..." remain intact. text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text) text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text) - text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text) + text = re.sub( + r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text + ) - return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/") + return text.replace(".specify/.specify/", ".specify/").replace( + ".specify.specify/", ".specify/" + ) def render_markdown_command( - self, - frontmatter: dict, - body: str, - source_id: str, - context_note: str = None + self, frontmatter: dict, body: str, source_id: str, context_note: str = None ) -> str: """Render command in Markdown format. @@ -172,12 +175,7 @@ def render_markdown_command( context_note = f"\n\n" return self.render_frontmatter(frontmatter) + "\n" + context_note + body - def render_toml_command( - self, - frontmatter: dict, - body: str, - source_id: str - ) -> str: + def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> str: """Render command in TOML format. Args: @@ -192,7 +190,7 @@ def render_toml_command( if "description" in frontmatter: toml_lines.append( - f'description = {self._render_basic_toml_string(frontmatter["description"])}' + f"description = {self._render_basic_toml_string(frontmatter['description'])}" ) toml_lines.append("") @@ -226,6 +224,41 @@ def _render_basic_toml_string(value: str) -> str: ) return f'"{escaped}"' + def render_yaml_command( + self, + frontmatter: dict, + body: str, + source_id: str, + cmd_name: str = "", + ) -> str: + """Render command in YAML recipe format for Goose. + + Args: + frontmatter: Command frontmatter + body: Command body content + source_id: Source identifier (extension or preset ID) + cmd_name: Command name used as title fallback + + Returns: + Formatted YAML recipe file content + """ + from specify_cli.integrations.base import YamlIntegration + + title = frontmatter.get("title", "") or frontmatter.get("name", "") + if not isinstance(title, str): + title = str(title) if title is not None else "" + if not title and cmd_name: + title = YamlIntegration._human_title(cmd_name) + if not title and source_id: + title = YamlIntegration._human_title(Path(str(source_id)).stem) + if not title: + title = "Command" + + description = frontmatter.get("description", "") + if not isinstance(description, str): + description = str(description) if description is not None else "" + return YamlIntegration._render_yaml(title, description, body, source_id) + def render_skill_command( self, agent_name: str, @@ -252,9 +285,13 @@ def render_skill_command( frontmatter = {} if agent_name in {"codex", "kimi"}: - body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) + body = self.resolve_skill_placeholders( + agent_name, frontmatter, body, project_root + ) - description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}") + description = frontmatter.get( + "description", f"Spec-kit workflow command: {skill_name}" + ) skill_frontmatter = self.build_skill_frontmatter( agent_name, skill_name, @@ -288,7 +325,9 @@ def build_skill_frontmatter( return skill_frontmatter @staticmethod - def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str: + def resolve_skill_placeholders( + agent_name: str, frontmatter: dict, body: str, project_root: Path + ) -> str: """Resolve script placeholders for skills-backed agents.""" try: from . import load_init_options @@ -312,7 +351,9 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr script_variant = init_opts.get("script") if script_variant not in {"sh", "ps"}: fallback_order = [] - default_variant = "ps" if platform.system().lower().startswith("win") else "sh" + default_variant = ( + "ps" if platform.system().lower().startswith("win") else "sh" + ) secondary_variant = "sh" if default_variant == "ps" else "ps" if default_variant in scripts or default_variant in agent_scripts: @@ -334,7 +375,9 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr script_command = script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{SCRIPT}", script_command) - agent_script_command = agent_scripts.get(script_variant) if script_variant else None + agent_script_command = ( + agent_scripts.get(script_variant) if script_variant else None + ) if agent_script_command: agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{AGENT_SCRIPT}", agent_script_command) @@ -342,7 +385,9 @@ def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, pr body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) return CommandRegistrar.rewrite_project_relative_paths(body) - def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str: + def _convert_argument_placeholder( + self, content: str, from_placeholder: str, to_placeholder: str + ) -> str: """Convert argument placeholder format. Args: @@ -356,14 +401,16 @@ def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_ return content.replace(from_placeholder, to_placeholder) @staticmethod - def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str: + def _compute_output_name( + agent_name: str, cmd_name: str, agent_config: Dict[str, Any] + ) -> str: """Compute the on-disk command or skill name for an agent.""" if agent_config["extension"] != "/SKILL.md": return cmd_name short_name = cmd_name if short_name.startswith("speckit."): - short_name = short_name[len("speckit."):] + short_name = short_name[len("speckit.") :] short_name = short_name.replace(".", "-") return f"speckit-{short_name}" @@ -375,7 +422,7 @@ def register_commands( source_id: str, source_dir: Path, project_root: Path, - context_note: str = None + context_note: str = None, ) -> List[str]: """Register commands for a specific agent. @@ -432,12 +479,24 @@ def register_commands( if agent_config["extension"] == "/SKILL.md": output = self.render_skill_command( - agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root + agent_name, + output_name, + frontmatter, + body, + source_id, + cmd_file, + project_root, ) elif agent_config["format"] == "markdown": - output = self.render_markdown_command(frontmatter, body, source_id, context_note) + output = self.render_markdown_command( + frontmatter, body, source_id, context_note + ) elif agent_config["format"] == "toml": output = self.render_toml_command(frontmatter, body, source_id) + elif agent_config["format"] == "yaml": + output = self.render_yaml_command( + frontmatter, body, source_id, cmd_name + ) else: raise ValueError(f"Unsupported format: {agent_config['format']}") @@ -451,34 +510,68 @@ def register_commands( registered.append(cmd_name) for alias in cmd_info.get("aliases", []): - alias_output_name = self._compute_output_name(agent_name, alias, agent_config) + alias_output_name = self._compute_output_name( + agent_name, alias, agent_config + ) # For agents with inject_name, render with alias-specific frontmatter if agent_config.get("inject_name"): alias_frontmatter = deepcopy(frontmatter) # Use custom name formatter if provided (e.g., Forge's hyphenated format) format_name = agent_config.get("format_name") - alias_frontmatter["name"] = format_name(alias) if format_name else alias + alias_frontmatter["name"] = ( + format_name(alias) if format_name else alias + ) if agent_config["extension"] == "/SKILL.md": alias_output = self.render_skill_command( - agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root + agent_name, + alias_output_name, + alias_frontmatter, + body, + source_id, + cmd_file, + project_root, ) elif agent_config["format"] == "markdown": - alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note) + alias_output = self.render_markdown_command( + alias_frontmatter, body, source_id, context_note + ) elif agent_config["format"] == "toml": - alias_output = self.render_toml_command(alias_frontmatter, body, source_id) + alias_output = self.render_toml_command( + alias_frontmatter, body, source_id + ) + elif agent_config["format"] == "yaml": + alias_output = self.render_yaml_command( + alias_frontmatter, body, source_id, alias + ) else: - raise ValueError(f"Unsupported format: {agent_config['format']}") + raise ValueError( + f"Unsupported format: {agent_config['format']}" + ) else: # For other agents, reuse the primary output alias_output = output if agent_config["extension"] == "/SKILL.md": alias_output = self.render_skill_command( - agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root + agent_name, + alias_output_name, + frontmatter, + body, + source_id, + cmd_file, + project_root, ) - alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}" + alias_file = ( + commands_dir / f"{alias_output_name}{agent_config['extension']}" + ) + try: + alias_file.resolve().relative_to(commands_dir.resolve()) + except ValueError: + raise ValueError( + f"Alias output path escapes commands directory: {alias_file!r}" + ) alias_file.parent.mkdir(parents=True, exist_ok=True) alias_file.write_text(alias_output, encoding="utf-8") if agent_name == "copilot": @@ -506,7 +599,7 @@ def register_commands_for_all_agents( source_id: str, source_dir: Path, project_root: Path, - context_note: str = None + context_note: str = None, ) -> Dict[str, List[str]]: """Register commands for all detected agents in the project. @@ -529,8 +622,12 @@ def register_commands_for_all_agents( if agent_dir.exists(): try: registered = self.register_commands( - agent_name, commands, source_id, source_dir, project_root, - context_note=context_note + agent_name, + commands, + source_id, + source_dir, + project_root, + context_note=context_note, ) if registered: results[agent_name] = registered @@ -540,9 +637,7 @@ def register_commands_for_all_agents( return results def unregister_commands( - self, - registered_commands: Dict[str, List[str]], - project_root: Path + self, registered_commands: Dict[str, List[str]], project_root: Path ) -> None: """Remove previously registered command files from agent directories. @@ -559,13 +654,17 @@ def unregister_commands( commands_dir = project_root / agent_config["dir"] for cmd_name in cmd_names: - output_name = self._compute_output_name(agent_name, cmd_name, agent_config) + output_name = self._compute_output_name( + agent_name, cmd_name, agent_config + ) cmd_file = commands_dir / f"{output_name}{agent_config['extension']}" if cmd_file.exists(): cmd_file.unlink() if agent_name == "copilot": - prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + prompt_file = ( + project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md" + ) if prompt_file.exists(): prompt_file.unlink() diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 3eb58622e7..a5fb3833dc 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -36,6 +36,7 @@ def get_integration(key: str) -> IntegrationBase | None: # -- Register built-in integrations -------------------------------------- + def _register_builtins() -> None: """Register all built-in integrations. @@ -58,6 +59,7 @@ def _register_builtins() -> None: from .forge import ForgeIntegration from .gemini import GeminiIntegration from .generic import GenericIntegration + from .goose import GooseIntegration from .iflow import IflowIntegration from .junie import JunieIntegration from .kilocode import KilocodeIntegration @@ -87,6 +89,7 @@ def _register_builtins() -> None: _register(ForgeIntegration()) _register(GeminiIntegration()) _register(GenericIntegration()) + _register(GooseIntegration()) _register(IflowIntegration()) _register(JunieIntegration()) _register(KilocodeIntegration()) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 1b09347dcd..87eca9d3bf 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -28,6 +28,7 @@ # IntegrationOption # --------------------------------------------------------------------------- + @dataclass(frozen=True) class IntegrationOption: """Declares an option that an integration accepts via ``--integration-options``. @@ -51,6 +52,7 @@ class IntegrationOption: # IntegrationBase — abstract base class # --------------------------------------------------------------------------- + class IntegrationBase(ABC): """Abstract base class every integration must implement. @@ -275,7 +277,7 @@ def process_template( 2. Replace ``{SCRIPT}`` with the extracted script command 3. Extract ``agent_scripts.`` and replace ``{AGENT_SCRIPT}`` 4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter - 5. Replace ``{ARGS}`` with *arg_placeholder* + 5. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* 6. Replace ``__AGENT__`` with *agent_name* 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. """ @@ -348,8 +350,9 @@ def process_template( output_lines.append(line) content = "".join(output_lines) - # 5. Replace {ARGS} + # 5. Replace {ARGS} and $ARGUMENTS content = content.replace("{ARGS}", arg_placeholder) + content = content.replace("$ARGUMENTS", arg_placeholder) # 6. Replace __AGENT__ content = content.replace("__AGENT__", agent_name) @@ -358,6 +361,7 @@ def process_template( # CommandRegistrar so extension-local paths are preserved and # boundary rules stay consistent across the codebase. from specify_cli.agents import CommandRegistrar + content = CommandRegistrar.rewrite_project_relative_paths(content) return content @@ -433,9 +437,7 @@ def install( **opts: Any, ) -> list[Path]: """High-level install — calls ``setup()`` and returns created files.""" - return self.setup( - project_root, manifest, parsed_options=parsed_options, **opts - ) + return self.setup(project_root, manifest, parsed_options=parsed_options, **opts) def uninstall( self, @@ -452,6 +454,7 @@ def uninstall( # MarkdownIntegration — covers ~20 standard agents # --------------------------------------------------------------------------- + class MarkdownIntegration(IntegrationBase): """Concrete base for integrations that use standard Markdown commands. @@ -492,12 +495,18 @@ def setup( dest.mkdir(parents=True, exist_ok=True) script_type = opts.get("script_type", "sh") - arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS" + arg_placeholder = ( + self.registrar_config.get("args", "$ARGUMENTS") + if self.registrar_config + else "$ARGUMENTS" + ) created: list[Path] = [] for src_file in templates: raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder + ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( processed, dest / dst_name, project_root, manifest @@ -512,6 +521,7 @@ def setup( # TomlIntegration — TOML-format agents (Gemini, Tabnine) # --------------------------------------------------------------------------- + class TomlIntegration(IntegrationBase): """Concrete base for integrations that use TOML command format. @@ -603,13 +613,17 @@ def _render_toml_string(value: str) -> str: if "'''" not in value and not value.endswith("'"): return "'''\n" + value + "'''" - return '"' + ( - value.replace("\\", "\\\\") - .replace('"', '\\"') - .replace("\n", "\\n") - .replace("\r", "\\r") - .replace("\t", "\\t") - ) + '"' + return ( + '"' + + ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + + '"' + ) @staticmethod def _render_toml(description: str, body: str) -> str: @@ -628,7 +642,9 @@ def _render_toml(description: str, body: str) -> str: toml_lines: list[str] = [] if description: - toml_lines.append(f"description = {TomlIntegration._render_toml_string(description)}") + toml_lines.append( + f"description = {TomlIntegration._render_toml_string(description)}" + ) toml_lines.append("") body = body.rstrip("\n") @@ -665,13 +681,19 @@ def setup( dest.mkdir(parents=True, exist_ok=True) script_type = opts.get("script_type", "sh") - arg_placeholder = self.registrar_config.get("args", "{{args}}") if self.registrar_config else "{{args}}" + arg_placeholder = ( + self.registrar_config.get("args", "{{args}}") + if self.registrar_config + else "{{args}}" + ) created: list[Path] = [] for src_file in templates: raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder + ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) dst_name = self.command_filename(src_file.stem) @@ -684,6 +706,188 @@ def setup( return created +# --------------------------------------------------------------------------- +# YamlIntegration — YAML-format agents (Goose) +# --------------------------------------------------------------------------- + + +class YamlIntegration(IntegrationBase): + """Concrete base for integrations that use YAML recipe format. + + Mirrors ``TomlIntegration`` closely: subclasses only need to set + ``key``, ``config``, ``registrar_config`` (and optionally + ``context_file``). Everything else is inherited. + + ``setup()`` processes command templates through the same placeholder + pipeline as ``MarkdownIntegration``, then converts the result to + YAML recipe format (version, title, description, prompt block scalar). + """ + + def command_filename(self, template_name: str) -> str: + """YAML commands use ``.yaml`` extension.""" + return f"speckit.{template_name}.yaml" + + @staticmethod + def _extract_frontmatter(content: str) -> dict[str, Any]: + """Extract frontmatter as a dict from YAML frontmatter block.""" + import yaml + + if not content.startswith("---"): + return {} + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return {} + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return {} + + frontmatter_text = "".join(lines[1:frontmatter_end]) + try: + fm = yaml.safe_load(frontmatter_text) or {} + except yaml.YAMLError: + return {} + + return fm if isinstance(fm, dict) else {} + + @staticmethod + def _split_frontmatter(content: str) -> tuple[str, str]: + """Split YAML frontmatter from the remaining body content.""" + if not content.startswith("---"): + return "", content + + lines = content.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return "", content + + frontmatter_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + frontmatter_end = i + break + + if frontmatter_end == -1: + return "", content + + frontmatter = "".join(lines[1:frontmatter_end]) + body = "".join(lines[frontmatter_end + 1 :]) + return frontmatter, body + + @staticmethod + def _human_title(identifier: str) -> str: + """Convert an identifier to a human-readable title. + + Strips a leading ``speckit.`` prefix and replaces ``.``, ``-``, + and ``_`` with spaces before title-casing. + """ + text = identifier + if text.startswith("speckit."): + text = text[len("speckit.") :] + return text.replace(".", " ").replace("-", " ").replace("_", " ").title() + + @staticmethod + def _render_yaml(title: str, description: str, body: str, source_id: str) -> str: + """Render a YAML recipe file from title, description, and body. + + Produces a Goose-compatible recipe with a literal block scalar + for the prompt content. Uses ``yaml.safe_dump()`` for the + header fields to ensure proper escaping. + """ + import yaml + + header = { + "version": "1.0.0", + "title": title, + "description": description, + "author": {"contact": "spec-kit"}, + "extensions": [{"type": "builtin", "name": "developer"}], + "activities": ["Spec-Driven Development"], + } + + header_yaml = yaml.safe_dump( + header, + sort_keys=False, + allow_unicode=True, + default_flow_style=False, + ).strip() + + # Indent each line for YAML block scalar + indented = "\n".join(f" {line}" for line in body.split("\n")) + + lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"] + return "\n".join(lines) + "\n" + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + templates = self.list_command_templates() + if not templates: + return [] + + project_root_resolved = project_root.resolve() + if manifest.project_root != project_root_resolved: + raise ValueError( + f"manifest.project_root ({manifest.project_root}) does not match " + f"project_root ({project_root_resolved})" + ) + + dest = self.commands_dest(project_root).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc + dest.mkdir(parents=True, exist_ok=True) + + script_type = opts.get("script_type", "sh") + arg_placeholder = ( + self.registrar_config.get("args", "{{args}}") + if self.registrar_config + else "{{args}}" + ) + created: list[Path] = [] + + for src_file in templates: + raw = src_file.read_text(encoding="utf-8") + fm = self._extract_frontmatter(raw) + description = fm.get("description", "") + if not isinstance(description, str): + description = str(description) if description is not None else "" + title = fm.get("title", "") or fm.get("name", "") + if not isinstance(title, str): + title = str(title) if title is not None else "" + if not title: + title = self._human_title(src_file.stem) + + processed = self.process_template( + raw, self.key, script_type, arg_placeholder + ) + _, body = self._split_frontmatter(processed) + yaml_content = self._render_yaml( + title, description, body, f"templates/commands/{src_file.name}" + ) + dst_name = self.command_filename(src_file.stem) + dst_file = self.write_file_and_record( + yaml_content, dest / dst_name, project_root, manifest + ) + created.append(dst_file) + + created.extend(self.install_scripts(project_root, manifest)) + return created + + # --------------------------------------------------------------------------- # SkillsIntegration — skills-format agents (Codex, Kimi, Agy) # --------------------------------------------------------------------------- @@ -713,9 +917,7 @@ def skills_dest(self, project_root: Path) -> Path: Raises ``ValueError`` when ``config`` or ``folder`` is missing. """ if not self.config: - raise ValueError( - f"{type(self).__name__}.config is not set." - ) + raise ValueError(f"{type(self).__name__}.config is not set.") folder = self.config.get("folder") if not folder: raise ValueError( diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py new file mode 100644 index 0000000000..0fc4d9d57a --- /dev/null +++ b/src/specify_cli/integrations/goose/__init__.py @@ -0,0 +1,21 @@ +"""Goose integration — Block's open source AI agent.""" + +from ..base import YamlIntegration + + +class GooseIntegration(YamlIntegration): + key = "goose" + config = { + "name": "Goose", + "folder": ".goose/", + "commands_subdir": "recipes", + "install_url": "https://block.github.io/goose/docs/getting-started/installation", + "requires_cli": True, + } + registrar_config = { + "dir": ".goose/recipes", + "format": "yaml", + "args": "{{args}}", + "extension": ".yaml", + } + context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/goose/scripts/update-context.ps1 b/src/specify_cli/integrations/goose/scripts/update-context.ps1 new file mode 100644 index 0000000000..eeb31f6296 --- /dev/null +++ b/src/specify_cli/integrations/goose/scripts/update-context.ps1 @@ -0,0 +1,33 @@ +# update-context.ps1 — Goose integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +$ErrorActionPreference = 'Stop' + +# Derive repo root from script location (walks up to find .specify/) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } +# If git did not return a repo root, or the git root does not contain .specify, +# fall back to walking up from the script directory to find the initialized project root. +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = $scriptDir + $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) + while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { + $repoRoot = Split-Path -Parent $repoRoot + } +} + +$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" + +# Always delegate to the shared updater; fail clearly if it is unavailable. +if (-not (Test-Path $sharedScript)) { + Write-Error "Error: shared agent context updater not found: $sharedScript" + Write-Error "Goose integration requires support in scripts/powershell/update-agent-context.ps1." + exit 1 +} + +& $sharedScript -AgentType goose +exit $LASTEXITCODE diff --git a/src/specify_cli/integrations/goose/scripts/update-context.sh b/src/specify_cli/integrations/goose/scripts/update-context.sh new file mode 100755 index 0000000000..759ae3045a --- /dev/null +++ b/src/specify_cli/integrations/goose/scripts/update-context.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# update-context.sh — Goose integration: create/update AGENTS.md +# +# Thin wrapper that delegates to the shared update-agent-context script. +# Activated in Stage 7 when the shared script uses integration.json dispatch. +# +# Until then, this delegates to the shared script as a subprocess. + +set -euo pipefail + +# Derive repo root from script location (walks up to find .specify/) +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_root="$_script_dir" +while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done +if [ -z "${REPO_ROOT:-}" ]; then + if [ -d "$_root/.specify" ]; then + REPO_ROOT="$_root" + else + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then + REPO_ROOT="$git_root" + else + REPO_ROOT="$_root" + fi + fi +fi + +shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" + +# Always delegate to the shared updater; fail clearly if it is unavailable. +if [ ! -x "$shared_script" ]; then + echo "Error: shared agent context updater not found or not executable:" >&2 + echo " $shared_script" >&2 + echo "Goose integration requires support in scripts/bash/update-agent-context.sh." >&2 + exit 1 +fi + +exec "$shared_script" goose diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index fcded1834e..4d0bfe2cfe 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -84,7 +84,9 @@ def test_setup_writes_to_correct_directory(self, tmp_path): m = IntegrationManifest(self.KEY, tmp_path) created = i.setup(tmp_path, m) expected_dir = i.commands_dest(tmp_path) - assert expected_dir.exists(), f"Expected directory {expected_dir} was not created" + assert expected_dir.exists(), ( + f"Expected directory {expected_dir} was not created" + ) cmd_files = [f for f in created if "scripts" not in f.parts] assert len(cmd_files) > 0, "No command files were created" for f in cmd_files: @@ -134,6 +136,12 @@ def test_toml_uses_correct_arg_placeholder(self, tmp_path): # At least one file should contain {{args}} from the {ARGS} placeholder has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files) assert has_args, "No TOML command file contains {{args}} placeholder" + has_dollar_args = any( + "$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files + ) + assert not has_dollar_args, ( + "TOML command still contains $ARGUMENTS instead of {{args}}" + ) @pytest.mark.parametrize( ("frontmatter", "expected"), @@ -156,19 +164,13 @@ def test_toml_uses_correct_arg_placeholder(self, tmp_path): ), ], ) - def test_toml_extract_description_supports_block_scalars(self, frontmatter, expected): + def test_toml_extract_description_supports_block_scalars( + self, frontmatter, expected + ): assert TomlIntegration._extract_description(frontmatter) == expected def test_split_frontmatter_ignores_indented_delimiters(self): - content = ( - "---\n" - "description: |\n" - " line one\n" - " ---\n" - " line two\n" - "---\n" - "Body\n" - ) + content = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n" frontmatter, body = TomlIntegration._split_frontmatter(content) @@ -205,7 +207,7 @@ def test_toml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "---" not in parsed["prompt"] def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch): - """Multiline body ending with `"` must not produce `""""` (#2113).""" + """Multiline body ending with a double quote must not produce an ambiguous TOML multiline-string closing delimiter (#2113).""" i = get_integration(self.KEY) template = tmp_path / "sample.md" template.write_text( @@ -230,7 +232,9 @@ def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch): assert '"""\n' in raw, "body must use multiline basic string" parsed = tomllib.loads(raw) assert parsed["prompt"].endswith('specified?"') - assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline" + assert not parsed["prompt"].endswith("\n"), ( + "parsed value must not gain a trailing newline" + ) def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch): """Body containing `\"\"\"` and ending with `'` falls back to escaped basic string.""" @@ -254,11 +258,15 @@ def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch) assert len(cmd_files) == 1 raw = cmd_files[0].read_text(encoding="utf-8") - assert "''''" not in raw, "literal string must not produce ambiguous closing quotes" + assert "''''" not in raw, ( + "literal string must not produce ambiguous closing quotes" + ) parsed = tomllib.loads(raw) assert parsed["prompt"].endswith("'single'") assert '"""triple"""' in parsed["prompt"] - assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline" + assert not parsed["prompt"].endswith("\n"), ( + "parsed value must not gain a trailing newline" + ) def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch): """Body NOT ending with `"` keeps closing `\"\"\"` inline (no extra newline).""" @@ -284,8 +292,9 @@ def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch): raw = cmd_files[0].read_text(encoding="utf-8") parsed = tomllib.loads(raw) assert parsed["prompt"] == "Line one\nPlain body content" - assert raw.rstrip().endswith('content"""'), \ + assert raw.rstrip().endswith('content"""'), ( "closing delimiter should be inline when body does not end with a quote" + ) def test_toml_is_valid(self, tmp_path): """Every generated TOML file must parse without errors.""" @@ -354,7 +363,14 @@ def test_sh_script_is_executable(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" + sh = ( + tmp_path + / ".specify" + / "integrations" + / self.KEY + / "scripts" + / "update-context.sh" + ) assert os.access(sh, os.X_OK) # -- CLI auto-promote ------------------------------------------------- @@ -369,10 +385,20 @@ def test_ai_flag_auto_promotes(self, tmp_path): try: os.chdir(project) runner = CliRunner() - result = runner.invoke(app, [ - "init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git", - "--ignore-agent-tools", - ], catch_exceptions=False) + result = runner.invoke( + app, + [ + "init", + "--here", + "--ai", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" @@ -390,13 +416,25 @@ def test_integration_flag_creates_files(self, tmp_path): 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) + 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, f"init --integration {self.KEY} failed: {result.output}" + assert result.exit_code == 0, ( + f"init --integration {self.KEY} failed: {result.output}" + ) i = get_integration(self.KEY) cmd_dir = i.commands_dest(project) assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created" @@ -406,8 +444,15 @@ def test_integration_flag_creates_files(self, tmp_path): # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "analyze", "checklist", "clarify", "constitution", - "implement", "plan", "specify", "tasks", "taskstoissues", + "analyze", + "checklist", + "clarify", + "constitution", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", ] def _expected_files(self, script_variant: str) -> list[str]: @@ -425,23 +470,38 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") # Framework files - files.append(f".specify/integration.json") - files.append(f".specify/init-options.json") + files.append(".specify/integration.json") + files.append(".specify/init-options.json") files.append(f".specify/integrations/{self.KEY}.manifest.json") - files.append(f".specify/integrations/speckit.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", "update-agent-context.sh"]: + for name in [ + "check-prerequisites.sh", + "common.sh", + "create-new-feature.sh", + "setup-plan.sh", + "update-agent-context.sh", + ]: files.append(f".specify/scripts/bash/{name}") else: - for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", - "setup-plan.ps1", "update-agent-context.ps1"]: + for name in [ + "check-prerequisites.ps1", + "common.ps1", + "create-new-feature.ps1", + "setup-plan.ps1", + "update-agent-context.ps1", + ]: files.append(f".specify/scripts/powershell/{name}") - for name in ["agent-file-template.md", "checklist-template.md", - "constitution-template.md", "plan-template.md", - "spec-template.md", "tasks-template.md"]: + for name in [ + "agent-file-template.md", + "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") @@ -457,15 +517,26 @@ def test_complete_file_inventory_sh(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--no-git", "--ignore-agent-tools", - ], catch_exceptions=False) + result = CliRunner().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, f"init failed: {result.output}" - actual = sorted(p.relative_to(project).as_posix() - for p in project.rglob("*") if p.is_file()) + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) expected = self._expected_files("sh") assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" @@ -482,15 +553,26 @@ def test_complete_file_inventory_ps(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "ps", - "--no-git", "--ignore-agent-tools", - ], catch_exceptions=False) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "ps", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) finally: os.chdir(old_cwd) assert result.exit_code == 0, f"init failed: {result.output}" - actual = sorted(p.relative_to(project).as_posix() - for p in project.rglob("*") if p.is_file()) + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) expected = self._expected_files("ps") assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py new file mode 100644 index 0000000000..b0f59a627d --- /dev/null +++ b/tests/integrations/test_integration_base_yaml.py @@ -0,0 +1,459 @@ +"""Reusable test mixin for standard YamlIntegration subclasses. + +Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, +``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification +logic from ``YamlIntegrationTests``. + +Mirrors ``TomlIntegrationTests`` closely — same test structure, +adapted for YAML recipe output format. +""" + +import os + +import yaml + +from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration +from specify_cli.integrations.base import YamlIntegration +from specify_cli.integrations.manifest import IntegrationManifest + + +class YamlIntegrationTests: + """Mixin — set class-level constants and inherit these tests. + + Required class attrs on subclass:: + + KEY: str — integration registry key + FOLDER: str — e.g. ".goose/" + COMMANDS_SUBDIR: str — e.g. "recipes" + REGISTRAR_DIR: str — e.g. ".goose/recipes" + CONTEXT_FILE: str — e.g. "AGENTS.md" + """ + + KEY: str + FOLDER: str + COMMANDS_SUBDIR: str + REGISTRAR_DIR: str + CONTEXT_FILE: str + + # -- Registration ----------------------------------------------------- + + def test_registered(self): + assert self.KEY in INTEGRATION_REGISTRY + assert get_integration(self.KEY) is not None + + def test_is_yaml_integration(self): + assert isinstance(get_integration(self.KEY), YamlIntegration) + + # -- Config ----------------------------------------------------------- + + def test_config_folder(self): + i = get_integration(self.KEY) + assert i.config["folder"] == self.FOLDER + + def test_config_commands_subdir(self): + i = get_integration(self.KEY) + assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR + + def test_registrar_config(self): + i = get_integration(self.KEY) + assert i.registrar_config["dir"] == self.REGISTRAR_DIR + assert i.registrar_config["format"] == "yaml" + assert i.registrar_config["args"] == "{{args}}" + assert i.registrar_config["extension"] == ".yaml" + + def test_context_file(self): + i = get_integration(self.KEY) + assert i.context_file == self.CONTEXT_FILE + + # -- Setup / teardown ------------------------------------------------- + + def test_setup_creates_files(self, tmp_path): + 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] + for f in cmd_files: + assert f.exists() + assert f.name.startswith("speckit.") + assert f.name.endswith(".yaml") + + def test_setup_writes_to_correct_directory(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + expected_dir = i.commands_dest(tmp_path) + assert expected_dir.exists(), ( + f"Expected directory {expected_dir} was not created" + ) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0, "No command files were created" + for f in cmd_files: + assert f.resolve().parent == expected_dir.resolve(), ( + f"{f} is not under {expected_dir}" + ) + + def test_templates_are_processed(self, tmp_path): + """Command files must have placeholders replaced.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) > 0 + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + + def test_yaml_has_title(self, tmp_path): + """Every YAML recipe should have a title field.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "title:" in content, f"{f.name} missing title field" + + def test_yaml_has_prompt(self, tmp_path): + """Every YAML recipe should have a prompt block scalar.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + assert "prompt: |" in content, f"{f.name} missing prompt block scalar" + + def test_yaml_uses_correct_arg_placeholder(self, tmp_path): + """YAML recipes must use {{args}} placeholder.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files) + assert has_args, "No YAML recipe contains {{args}} placeholder" + has_dollar_args = any( + "$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files + ) + assert not has_dollar_args, ( + "YAML recipe still contains $ARGUMENTS instead of {{args}}" + ) + + def test_yaml_is_valid(self, tmp_path): + """Every generated YAML file must parse without errors.""" + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + for f in cmd_files: + content = f.read_text(encoding="utf-8") + # Strip trailing source comment before parsing + lines = content.split("\n") + yaml_lines = [l for l in lines if not l.startswith("# Source:")] + try: + parsed = yaml.safe_load("\n".join(yaml_lines)) + except Exception as exc: + raise AssertionError(f"{f.name} is not valid YAML: {exc}") from exc + assert "prompt" in parsed, f"{f.name} parsed YAML has no 'prompt' key" + assert "title" in parsed, f"{f.name} parsed YAML has no 'title' key" + + def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): + i = get_integration(self.KEY) + template = tmp_path / "sample.md" + template.write_text( + "---\n" + "description: Summary line one\n" + "scripts:\n" + " sh: scripts/bash/example.sh\n" + "---\n" + "Body line one\n" + "Body line two\n", + encoding="utf-8", + ) + monkeypatch.setattr(i, "list_command_templates", lambda: [template]) + + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + cmd_files = [f for f in created if "scripts" not in f.parts] + assert len(cmd_files) == 1 + + content = cmd_files[0].read_text(encoding="utf-8") + # Strip source comment for parsing + lines = content.split("\n") + yaml_lines = [l for l in lines if not l.startswith("# Source:")] + parsed = yaml.safe_load("\n".join(yaml_lines)) + + assert "description:" not in parsed["prompt"] + assert "scripts:" not in parsed["prompt"] + assert "---" not in parsed["prompt"] + + def test_all_files_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + def test_install_uninstall_roundtrip(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = i.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.install(tmp_path, m) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = i.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + # -- Scripts ---------------------------------------------------------- + + def test_setup_installs_update_context_scripts(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m) + scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" + assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" + assert (scripts_dir / "update-context.sh").exists() + assert (scripts_dir / "update-context.ps1").exists() + + def test_scripts_tracked_in_manifest(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + script_rels = [k for k in m.files if "update-context" in k] + assert len(script_rels) >= 2 + + def test_sh_script_is_executable(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + sh = ( + tmp_path + / ".specify" + / "integrations" + / self.KEY + / "scripts" + / "update-context.sh" + ) + assert os.access(sh, os.X_OK) + + # -- CLI auto-promote ------------------------------------------------- + + def test_ai_flag_auto_promotes(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"promote-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--ai", + self.KEY, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}" + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory" + + 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, ( + f"init --integration {self.KEY} failed: {result.output}" + ) + i = get_integration(self.KEY) + cmd_dir = i.commands_dest(project) + assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created" + commands = sorted(cmd_dir.glob("speckit.*.yaml")) + assert len(commands) > 0, f"No command files in {cmd_dir}" + + # -- Complete file inventory ------------------------------------------ + + COMMAND_STEMS = [ + "analyze", + "checklist", + "clarify", + "constitution", + "implement", + "plan", + "specify", + "tasks", + "taskstoissues", + ] + + def _expected_files(self, script_variant: str) -> list[str]: + """Build the expected file list for this integration + script variant.""" + i = get_integration(self.KEY) + cmd_dir = i.registrar_config["dir"] + files = [] + + # Command files (.yaml) + for stem in self.COMMAND_STEMS: + files.append(f"{cmd_dir}/speckit.{stem}.yaml") + + # Integration scripts + files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") + files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") + + # 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", + "update-agent-context.sh", + ]: + files.append(f".specify/scripts/bash/{name}") + else: + for name in [ + "check-prerequisites.ps1", + "common.ps1", + "create-new-feature.ps1", + "setup-plan.ps1", + "update-agent-context.ps1", + ]: + files.append(f".specify/scripts/powershell/{name}") + + for name in [ + "agent-file-template.md", + "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") + return sorted(files) + + def test_complete_file_inventory_sh(self, tmp_path): + """Every file produced by specify init --integration --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-sh-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().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, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("sh") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + def test_complete_file_inventory_ps(self, tmp_path): + """Every file produced by specify init --integration --script ps.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"inventory-ps-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke( + app, + [ + "init", + "--here", + "--integration", + self.KEY, + "--script", + "ps", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted( + p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() + ) + expected = self._expected_files("ps") + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py new file mode 100644 index 0000000000..6483666f36 --- /dev/null +++ b/tests/integrations/test_integration_goose.py @@ -0,0 +1,11 @@ +"""Tests for GooseIntegration.""" + +from .test_integration_base_yaml import YamlIntegrationTests + + +class TestGooseIntegration(YamlIntegrationTests): + KEY = "goose" + FOLDER = ".goose/" + COMMANDS_SUBDIR = "recipes" + REGISTRAR_DIR = ".goose/recipes" + CONTEXT_FILE = "AGENTS.md" diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 35d8c02f7e..9cfe1ddbc9 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -50,16 +50,25 @@ def test_init_ai_help_includes_roo_and_kiro_alias(self): def test_devcontainer_kiro_installer_uses_pinned_checksum(self): """Devcontainer installer should always verify Kiro installer via pinned SHA256.""" - post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(encoding="utf-8") - - assert 'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"' in post_create_text + post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text( + encoding="utf-8" + ) + + assert ( + 'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"' + in post_create_text + ) assert "sha256sum -c -" in post_create_text assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text def test_agent_context_scripts_use_kiro_cli(self): """Agent context scripts should advertise kiro-cli and not legacy q agent key.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") assert "kiro-cli" in bash_text assert "kiro-cli" in pwsh_text @@ -89,8 +98,12 @@ def test_extension_registrar_includes_tabnine(self): def test_agent_context_scripts_include_tabnine(self): """Agent context scripts should support tabnine agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") assert "tabnine" in bash_text assert "TABNINE_FILE" in bash_text @@ -121,7 +134,9 @@ def test_kimi_in_extension_registrar(self): def test_kimi_in_powershell_validate_set(self): """PowerShell update-agent-context script should include 'kimi' in ValidateSet.""" - ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + ps_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) assert validate_set_match is not None @@ -155,8 +170,12 @@ def test_trae_in_extension_registrar(self): def test_trae_in_agent_context_scripts(self): """Agent context scripts should support trae agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") assert "trae" in bash_text assert "TRAE_FILE" in bash_text @@ -165,7 +184,9 @@ def test_trae_in_agent_context_scripts(self): def test_trae_in_powershell_validate_set(self): """PowerShell update-agent-context script should include 'trae' in ValidateSet.""" - ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + ps_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) assert validate_set_match is not None @@ -200,7 +221,9 @@ def test_pi_in_extension_registrar(self): def test_pi_in_powershell_validate_set(self): """PowerShell update-agent-context script should include 'pi' in ValidateSet.""" - ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + ps_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) assert validate_set_match is not None @@ -210,8 +233,12 @@ def test_pi_in_powershell_validate_set(self): def test_agent_context_scripts_include_pi(self): """Agent context scripts should support pi agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") assert "pi" in bash_text assert "Pi Coding Agent" in bash_text @@ -242,8 +269,12 @@ def test_iflow_in_extension_registrar(self): def test_iflow_in_agent_context_scripts(self): """Agent context scripts should support iflow agent type.""" - bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8") - pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8") + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") assert "iflow" in bash_text assert "IFLOW_FILE" in bash_text @@ -253,3 +284,37 @@ def test_iflow_in_agent_context_scripts(self): def test_ai_help_includes_iflow(self): """CLI help text for --ai should include iflow.""" assert "iflow" in AI_ASSISTANT_HELP + + # --- Goose consistency checks --- + + def test_goose_in_agent_config(self): + """AGENT_CONFIG should include goose with correct folder and commands_subdir.""" + assert "goose" in AGENT_CONFIG + assert AGENT_CONFIG["goose"]["folder"] == ".goose/" + assert AGENT_CONFIG["goose"]["commands_subdir"] == "recipes" + assert AGENT_CONFIG["goose"]["requires_cli"] is True + + def test_goose_in_extension_registrar(self): + """Extension command registrar should include goose targeting .goose/recipes.""" + cfg = CommandRegistrar.AGENT_CONFIGS + + assert "goose" in cfg + assert cfg["goose"]["dir"] == ".goose/recipes" + assert cfg["goose"]["format"] == "yaml" + assert cfg["goose"]["args"] == "{{args}}" + + def test_goose_in_agent_context_scripts(self): + """Agent context scripts should support goose agent type.""" + bash_text = ( + REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" + ).read_text(encoding="utf-8") + pwsh_text = ( + REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" + ).read_text(encoding="utf-8") + + assert "goose" in bash_text + assert "goose" in pwsh_text + + def test_ai_help_includes_goose(self): + """CLI help text for --ai should include goose.""" + assert "goose" in AI_ASSISTANT_HELP From e27896e68174ec8f106964043e3f9b820450ecd2 Mon Sep 17 00:00:00 2001 From: Fatima367 <170196704+Fatima367@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:22:46 +0500 Subject: [PATCH 036/184] feat: add GitHub Issues Integration to community catalog (#2188) * feat: add GitHub Issues Integration to community catalog Add GitHub Issues Integration extension to the community catalog and README. Extension provides: - /speckit.github-issues.import - Import GitHub Issue and generate spec.md - /speckit.github-issues.sync - Keep specs updated with issue changes - /speckit.github-issues.link - Add bidirectional traceability Resolves #2175 * Modify created_at and updated_at timestamps Updated timestamps for created_at and updated_at fields. * Update updated_at date in catalog.community.json --- README.md | 1 + extensions/catalog.community.json | 44 +++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e366ad5b13..1d9baf09e1 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ The following community-contributed extensions are available in [`catalog.commun | Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | | FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | +| GitHub Issues Integration | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | | 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) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ec7ad87c55..0c80a87669 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-12T19:00:00Z", + "updated_at": "2026-04-13T14:39:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -649,6 +649,46 @@ "created_at": "2026-03-06T00:00:00Z", "updated_at": "2026-03-31T00:00:00Z" }, + "github-issues": { + "name": "GitHub Issues Integration", + "id": "github-issues", + "description": "Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability", + "author": "Fatima367", + "version": "1.0.0", + "download_url": "https://github.com/Fatima367/spec-kit-github-issues/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Fatima367/spec-kit-github-issues", + "homepage": "https://github.com/Fatima367/spec-kit-github-issues", + "documentation": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/README.md", + "changelog": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "gh", + "version": ">=2.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "integration", + "github", + "issues", + "import", + "sync", + "traceability" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-12T15:30:00Z", + "updated_at": "2026-04-13T14:39:00Z" + }, "iterate": { "name": "Iterate", "id": "iterate", @@ -1911,4 +1951,4 @@ "updated_at": "2026-04-09T00:00:00Z" } } -} \ No newline at end of file +} From aa85b2f166aff608176363da08c4d4c7afc18a88 Mon Sep 17 00:00:00 2001 From: Abdullah Khan <136432132+DevAbdullah90@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:01:37 +0500 Subject: [PATCH 037/184] feat: Register "What-if Analysis" community extension (#2182) * feat: implement read-only what-if analysis command * chore: polish what-if analysis (Claude hints + optional tasks) * refactor: deliver what-if analysis as a standalone extension * Move What-if extension to standalone repo and update community catalog * Fix: Reorder whatif extension alphabetically in community catalog --- README.md | 1 + extensions/catalog.community.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/README.md b/README.md index 1d9baf09e1..94fcdadc8e 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,7 @@ The following community-contributed extensions are available in [`catalog.commun | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | +| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) | | Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) | To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md). diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 0c80a87669..4f8accfef0 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1918,6 +1918,34 @@ "created_at": "2026-03-16T00:00:00Z", "updated_at": "2026-03-16T00:00:00Z" }, + "whatif": { + "name": "What-if Analysis", + "id": "whatif", + "description": "Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them.", + "author": "DevAbdullah90", + "version": "1.0.0", + "repository": "https://github.com/DevAbdullah90/spec-kit-whatif", + "homepage": "https://github.com/DevAbdullah90/spec-kit-whatif", + "documentation": "https://github.com/DevAbdullah90/spec-kit-whatif/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "analysis", + "planning", + "simulation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-13T00:00:00Z" + }, "worktree": { "name": "Worktree Isolation", "id": "worktree", From fe75a456272e105387b7f8985d885ae5d22d4c54 Mon Sep 17 00:00:00 2001 From: adaumann <94932945+adaumann@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:04:40 +0200 Subject: [PATCH 038/184] feat: Update catalog.community.json for preset-fiction-book-writing (#2199) * feat: Update catalog.community.json for preset-fiction-book-writing * Add fiction-book-writing preset to community catalog - Preset ID: fiction-book-writing - Version: 1.3.0 - Author: Andreas Daumann - Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * doc: added fiction-book-writing preset link in README.md * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- README.md | 1 + presets/catalog.community.json | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/README.md b/README.md index 94fcdadc8e..6647c5657d 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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) | | 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) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes, author voice sample or humanized AI prose. | 21 templates, 17 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | 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) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 378b264c17..b212037661 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -78,6 +78,40 @@ "wave-dag" ] }, + "fiction-book-writing": { + "name": "Fiction Book Writing", + "id": "fiction-book-writing", + "version": "1.3.0", + "description": "Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc.", + "author": "Andreas Daumann", + "repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing", + "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.3.0.zip", + "homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing", + "documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.5.0" + }, + "provides": { + "templates": 21, + "commands": 17, + "scripts": 1 + }, + "tags": [ + "writing", + "novel", + "book", + "fiction", + "storytelling", + "creative-writing", + "kdp", + "single-pov", + "multi-pov", + "export" + ], + "created_at": "2026-04-09T08:00:00Z", + "updated_at": "2026-04-09T08:00:00Z" + }, "multi-repo-branching": { "name": "Multi-Repo Branching", "id": "multi-repo-branching", From bb7da09b6584278ee8bde8b0414f4ea517ed71d7 Mon Sep 17 00:00:00 2001 From: dango85 Date: Mon, 13 Apr 2026 14:34:54 -0500 Subject: [PATCH 039/184] Add Worktrees extension to community catalog (#2207) - Extension ID: worktrees - Version: 1.0.0 - Author: dango85 - Description: Default-on worktree isolation for parallel agents Made-with: Cursor Co-authored-by: Abishek Yadav --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 6647c5657d..8b47146405 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ The following community-contributed extensions are available in [`catalog.commun | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | | What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) | | Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) | +| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) | To submit your own extension, see the [Extension Publishing Guide](extensions/EXTENSION-PUBLISHING-GUIDE.md). diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 4f8accfef0..4cfa66cb19 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1977,6 +1977,38 @@ "stars": 0, "created_at": "2026-04-09T00:00:00Z", "updated_at": "2026-04-09T00:00:00Z" + }, + "worktrees": { + "name": "Worktrees", + "id": "worktrees", + "description": "Default-on worktree isolation for parallel agents — sibling or nested layout", + "author": "dango85", + "version": "1.0.0", + "download_url": "https://github.com/dango85/spec-kit-worktree-parallel/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/dango85/spec-kit-worktree-parallel", + "homepage": "https://github.com/dango85/spec-kit-worktree-parallel", + "documentation": "https://github.com/dango85/spec-kit-worktree-parallel/blob/main/README.md", + "changelog": "https://github.com/dango85/spec-kit-worktree-parallel/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "worktree", + "git", + "parallel", + "isolation", + "agents" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-13T00:00:00Z" } } } From de93528fad7d43da2a872770e8e16c239d343f18 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:35:22 -0500 Subject: [PATCH 040/184] chore: release 0.6.2, begin 0.6.3.dev0 development (#2205) * chore: bump version to 0.6.2 * chore: begin 0.6.3.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd932a4b2..928bc74b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ +## [0.6.2] - 2026-04-13 + +### Changed + +- feat: Register "What-if Analysis" community extension (#2182) +- feat: add GitHub Issues Integration to community catalog (#2188) +- feat(agents): add Goose AI agent support (#2015) +- Update ralph extension to v1.0.1 in community catalog (#2192) +- fix: skip docs deployment workflow on forks (#2171) +- chore: release 0.6.1, begin 0.6.2.dev0 development (#2162) + ## [0.6.1] - 2026-04-10 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 7fec6cc6ed..7253358f78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.6.2.dev0" +version = "0.6.3.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 4687c33b0ff83181a7e1eb14bf76b3576a53539b Mon Sep 17 00:00:00 2001 From: Gabriel Henrique <51464658+gabrielhmsantos@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:11:17 -0300 Subject: [PATCH 041/184] feat(scripts): optional single-segment branch prefix for gitflow (#2202) * feat(scripts): optional single-segment branch prefix for gitflow - Add spec_kit_effective_branch_name / Get-SpecKitEffectiveBranchName: when branch matches prefix/rest with exactly one slash, validate and resolve specs/ using only the rest (e.g. feat/001-my-feature). - Wire into check_feature_branch, find_feature_dir_by_prefix (bash) and Test-FeatureBranch, Find-FeatureDirByPrefix + Get-FeaturePathsEnv (PS). - Align git extension git-common with core validation; remove unused get_feature_dir / Get-FeatureDir helpers. - Extend tests in test_timestamp_branches.py and test_git_extension.py. Made-with: Cursor * Update scripts/powershell/common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(ps): align feature-dir resolution errors with bash (no throw under Stop) Find-FeatureDirByPrefix: on ambiguous prefix matches, write errors to stderr and return $null instead of throwing, matching find_feature_dir_by_prefix. Get-FeaturePathsEnv: narrow try/catch to ConvertFrom-Json only; add Get-FeatureDirFromBranchPrefixOrExit to mirror bash get_feature_paths (stderr + exit 1) when prefix lookup fails, avoiding unhandled terminating errors under $ErrorActionPreference = 'Stop' in check-prerequisites, setup-plan, and update-agent-context. Made-with: Cursor * Update tests/test_timestamp_branches.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensions/git/scripts/powershell/git-common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update scripts/powershell/common.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/git/scripts/bash/git-common.sh | 39 ++++--- .../git/scripts/powershell/git-common.ps1 | 35 +++--- scripts/bash/common.sh | 23 +++- scripts/powershell/common.ps1 | 95 ++++++++++++---- tests/extensions/git/test_git_extension.py | 37 +++++++ tests/test_timestamp_branches.py | 101 ++++++++++++++++-- 6 files changed, 268 insertions(+), 62 deletions(-) diff --git a/extensions/git/scripts/bash/git-common.sh b/extensions/git/scripts/bash/git-common.sh index 882a385e28..b78356d1c6 100755 --- a/extensions/git/scripts/bash/git-common.sh +++ b/extensions/git/scripts/bash/git-common.sh @@ -11,10 +11,22 @@ has_git() { git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 } +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + # Validate that a branch name matches the expected feature branch pattern. # Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats. +# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization. check_feature_branch() { - local branch="$1" + local raw="$1" local has_git_repo="$2" # For non-git repos, we can't enforce branch naming but still provide output @@ -23,19 +35,20 @@ check_feature_branch() { return 0 fi - # Reject malformed timestamps (7-digit date, 8-digit date without trailing slug, or 7-digit with slug) - if [[ "$branch" =~ ^[0-9]{7}-[0-9]{6} ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 - return 1 - fi + local branch + branch=$(spec_kit_effective_branch_name "$raw") - # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) - if [[ "$branch" =~ ^[0-9]{3,}- ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then - return 0 + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + local is_sequential=false + if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then + is_sequential=true + fi + if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 + echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 + return 1 fi - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 - echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2 - return 1 + return 0 } diff --git a/extensions/git/scripts/powershell/git-common.ps1 b/extensions/git/scripts/powershell/git-common.ps1 index 8a9c4fd6cc..82210000b6 100644 --- a/extensions/git/scripts/powershell/git-common.ps1 +++ b/extensions/git/scripts/powershell/git-common.ps1 @@ -15,6 +15,14 @@ function Test-HasGit { } } +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + function Test-FeatureBranch { param( [string]$Branch, @@ -27,24 +35,17 @@ function Test-FeatureBranch { return $true } - # Reject malformed timestamps (7-digit date or no trailing slug) - $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or - ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') - if ($hasMalformedTimestamp) { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" - return $false - } + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw - # Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*) + # Accept sequential prefix (3+ digits) but exclude malformed timestamps + # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") + $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) - $isTimestamp = $Branch -match '^\d{8}-\d{6}-' - - if ($isSequential -or $isTimestamp) { - return $true + if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") + return $false } - - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" - return $false + return $true } diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 04af7d794f..b41d17dec3 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -114,8 +114,19 @@ has_git() { git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 } +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +spec_kit_effective_branch_name() { + local raw="$1" + if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then + printf '%s\n' "${BASH_REMATCH[2]}" + else + printf '%s\n' "$raw" + fi +} + check_feature_branch() { - local branch="$1" + local raw="$1" local has_git_repo="$2" # For non-git repos, we can't enforce branch naming but still provide output @@ -124,6 +135,9 @@ check_feature_branch() { return 0 fi + local branch + branch=$(spec_kit_effective_branch_name "$raw") + # Accept sequential prefix (3+ digits) but exclude malformed timestamps # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") local is_sequential=false @@ -131,7 +145,7 @@ check_feature_branch() { is_sequential=true fi if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then - echo "ERROR: Not on a feature branch. Current branch: $branch" >&2 + echo "ERROR: Not on a feature branch. Current branch: $raw" >&2 echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2 return 1 fi @@ -139,13 +153,12 @@ check_feature_branch() { return 0 } -get_feature_dir() { echo "$1/specs/$2"; } - # Find feature directory by numeric prefix instead of exact branch match # This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature) find_feature_dir_by_prefix() { local repo_root="$1" - local branch_name="$2" + local branch_name + branch_name=$(spec_kit_effective_branch_name "$2") local specs_dir="$repo_root/specs" # Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches) diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 35ed884f0f..0d6544aaf4 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -127,6 +127,16 @@ function Test-HasGit { } } +# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name"). +# Only when the full name is exactly two slash-free segments; otherwise returns the raw name. +function Get-SpecKitEffectiveBranchName { + param([string]$Branch) + if ($Branch -match '^([^/]+)/([^/]+)$') { + return $Matches[2] + } + return $Branch +} + function Test-FeatureBranch { param( [string]$Branch, @@ -138,22 +148,69 @@ function Test-FeatureBranch { Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation" return $true } + + $raw = $Branch + $Branch = Get-SpecKitEffectiveBranchName $raw # Accept sequential prefix (3+ digits) but exclude malformed timestamps # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022") $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$') $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp) if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') { - Write-Output "ERROR: Not on a feature branch. Current branch: $Branch" - Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" + [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw") + [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name") return $false } return $true } -function Get-FeatureDir { - param([string]$RepoRoot, [string]$Branch) - Join-Path $RepoRoot "specs/$Branch" +# Resolve specs/ by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix). +function Find-FeatureDirByPrefix { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$Branch + ) + $specsDir = Join-Path $RepoRoot 'specs' + $branchName = Get-SpecKitEffectiveBranchName $Branch + + $prefix = $null + if ($branchName -match '^(\d{8}-\d{6})-') { + $prefix = $Matches[1] + } elseif ($branchName -match '^(\d{3,})-') { + $prefix = $Matches[1] + } else { + return (Join-Path $specsDir $branchName) + } + + $dirMatches = @() + if (Test-Path -LiteralPath $specsDir -PathType Container) { + $dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue) + } + + if ($dirMatches.Count -eq 0) { + return (Join-Path $specsDir $branchName) + } + if ($dirMatches.Count -eq 1) { + return $dirMatches[0].FullName + } + $names = ($dirMatches | ForEach-Object { $_.Name }) -join ' ' + [Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names") + [Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.') + return $null +} + +# Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1). +function Get-FeatureDirFromBranchPrefixOrExit { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$CurrentBranch + ) + $resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch + if ($null -eq $resolved) { + [Console]::Error.WriteLine('ERROR: Failed to resolve feature directory') + exit 1 + } + return $resolved } function Get-FeaturePathsEnv { @@ -164,7 +221,7 @@ function Get-FeaturePathsEnv { # Resolve feature directory. Priority: # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override) # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify) - # 3. Exact branch-to-directory mapping via Get-FeatureDir (legacy fallback) + # 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh) $featureJson = Join-Path $repoRoot '.specify/feature.json' if ($env:SPECIFY_FEATURE_DIRECTORY) { $featureDir = $env:SPECIFY_FEATURE_DIRECTORY @@ -173,22 +230,24 @@ function Get-FeaturePathsEnv { $featureDir = Join-Path $repoRoot $featureDir } } elseif (Test-Path $featureJson) { + $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw try { - $featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json - if ($featureConfig.feature_directory) { - $featureDir = $featureConfig.feature_directory - # Normalize relative paths to absolute under repo root - if (-not [System.IO.Path]::IsPathRooted($featureDir)) { - $featureDir = Join-Path $repoRoot $featureDir - } - } else { - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch - } + $featureConfig = $featureJsonRaw | ConvertFrom-Json } catch { - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + [Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_") + exit 1 + } + if ($featureConfig.feature_directory) { + $featureDir = $featureConfig.feature_directory + # Normalize relative paths to absolute under repo root + if (-not [System.IO.Path]::IsPathRooted($featureDir)) { + $featureDir = Join-Path $repoRoot $featureDir + } + } else { + $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch } } else { - $featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch + $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch } [PSCustomObject]@{ diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 098caf53b7..50ab9c7b6b 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -587,3 +587,40 @@ def test_check_feature_branch_rejects_malformed_timestamp(self, tmp_path: Path): capture_output=True, text=True, ) assert result.returncode != 0 + + def test_check_feature_branch_accepts_single_prefix(self, tmp_path: Path): + """git-common check_feature_branch matches core: one optional path prefix.""" + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "feat/001-my-feature" "true"'], + capture_output=True, text=True, + ) + assert result.returncode == 0 + + def test_check_feature_branch_rejects_nested_prefix(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "bash" / "git-common.sh" + result = subprocess.run( + ["bash", "-c", f'source "{script}" && check_feature_branch "feat/fix/001-x" "true"'], + capture_output=True, text=True, + ) + assert result.returncode != 0 + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestGitCommonPowerShell: + def test_test_feature_branch_accepts_single_prefix(self, tmp_path: Path): + project = _setup_project(tmp_path) + script = project / ".specify" / "extensions" / "git" / "scripts" / "powershell" / "git-common.ps1" + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-Command", + f'. "{script}"; if (Test-FeatureBranch -Branch "feat/001-x" -HasGit $true) {{ exit 0 }} else {{ exit 1 }}', + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0 diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 2161d2893c..b258fa98d1 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -26,6 +26,13 @@ EXT_CREATE_FEATURE = PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" EXT_CREATE_FEATURE_PS = PROJECT_ROOT / "extensions" / "git" / "scripts" / "powershell" / "create-new-feature.ps1" +HAS_PWSH = shutil.which("pwsh") is not None + + +def _has_pwsh() -> bool: + """Check if pwsh is available.""" + return HAS_PWSH + @pytest.fixture def git_repo(tmp_path: Path) -> Path: @@ -271,6 +278,30 @@ def test_rejects_7digit_timestamp_without_slug(self): result = source_and_call('check_feature_branch "2026031-143022" "true"') assert result.returncode != 0 + def test_accepts_single_prefix_sequential(self): + """Optional gitflow-style prefix: one segment + sequential feature name.""" + result = source_and_call('check_feature_branch "feat/004-my-feature" "true"') + assert result.returncode == 0 + + def test_accepts_single_prefix_timestamp(self): + """Optional prefix + timestamp-style feature name.""" + result = source_and_call('check_feature_branch "release/20260319-143022-feat" "true"') + assert result.returncode == 0 + + def test_rejects_invalid_suffix_with_single_prefix(self): + result = source_and_call('check_feature_branch "feat/main" "true"') + assert result.returncode != 0 + assert "feat/main" in result.stderr + + def test_rejects_two_level_prefix_before_feature(self): + """More than one slash: no stripping; whole name must match (fails).""" + result = source_and_call('check_feature_branch "feat/fix/004-feat" "true"') + assert result.returncode != 0 + + def test_rejects_malformed_timestamp_with_prefix(self): + result = source_and_call('check_feature_branch "feat/2026031-143022-feat" "true"') + assert result.returncode != 0 + # ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── @@ -303,6 +334,67 @@ def test_four_digit_sequential_prefix(self, tmp_path: Path): assert result.returncode == 0 assert result.stdout.strip() == f"{tmp_path}/specs/1000-original-feat" + def test_sequential_with_single_path_prefix(self, tmp_path: Path): + """Strip one optional prefix segment before prefix directory lookup.""" + (tmp_path / "specs" / "004-only-dir").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "feat/004-other-suffix"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/004-only-dir" + + def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path): + (tmp_path / "specs" / "20260319-143022-canonical").mkdir(parents=True) + result = source_and_call( + f'find_feature_dir_by_prefix "{tmp_path}" "hotfix/20260319-143022-alias"' + ) + assert result.returncode == 0 + assert result.stdout.strip() == f"{tmp_path}/specs/20260319-143022-canonical" + + +# ── get_feature_paths + single-prefix integration ─────────────────────────── + + +class TestGetFeaturePathsSinglePrefix: + def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path): + """get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup.""" + (tmp_path / ".specify").mkdir() + (tmp_path / "specs" / "001-target-spec").mkdir(parents=True) + cmd = ( + f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && ' + f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"' + ) + result = subprocess.run( + ["bash", "-c", cmd], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec") + + @pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed") + def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): + """PowerShell Get-FeaturePathsEnv: same prefix stripping as bash.""" + common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" + spec_dir = git_repo / "specs" / "001-ps-prefix-spec" + spec_dir.mkdir(parents=True) + ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"' + result = subprocess.run( + ["pwsh", "-NoProfile", "-Command", ps_cmd], + cwd=git_repo, + capture_output=True, + text=True, + env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"}, + ) + assert result.returncode == 0, result.stderr + for line in result.stdout.splitlines(): + if line.startswith("FEATURE_DIR="): + val = line.split("=", 1)[1].strip() + assert val == str(spec_dir) + break + else: + pytest.fail("FEATURE_DIR not found in PowerShell output") + # ── get_current_branch Tests ───────────────────────────────────────────────── @@ -791,15 +883,6 @@ def test_dry_run_no_git(self, no_git_dir: Path): # ── PowerShell Dry-Run Tests ───────────────────────────────────────────────── -def _has_pwsh() -> bool: - """Check if pwsh is available.""" - try: - subprocess.run(["pwsh", "--version"], capture_output=True, check=True) - return True - except (FileNotFoundError, subprocess.CalledProcessError): - return False - - def run_ps_script(cwd: Path, *args: str) -> subprocess.CompletedProcess: """Run create-new-feature.ps1 from the temp repo's scripts directory.""" script = cwd / "scripts" / "powershell" / "create-new-feature.ps1" From 03a9163633a38b498ad70e5ce3e464edca46d961 Mon Sep 17 00:00:00 2001 From: ysumanth06 Date: Mon, 13 Apr 2026 18:12:27 -0500 Subject: [PATCH 042/184] =?UTF-8?q?Add=20SFSpeckit=20=E2=80=94=20Salesforc?= =?UTF-8?q?e=20SDD=20Extension=20(#2208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SFSpeckit — Salesforce SDD Extension * chore: update catalog updated_at timestamp --- README.md | 1 + extensions/catalog.community.json | 46 ++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b47146405..c729fe02f1 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ The following community-contributed extensions are available in [`catalog.commun | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | | Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | +| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) | | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | | Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 4cfa66cb19..61731b22d9 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-13T14:39:00Z", + "updated_at": "2026-04-13T23:01:30Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1529,6 +1529,50 @@ "created_at": "2026-04-03T03:24:03Z", "updated_at": "2026-04-03T04:15:00Z" }, + "sf": { + "name": "SFSpeckit — Salesforce Spec-Driven Development", + "id": "sf", + "description": "Enterprise-Grade Spec-Driven Development (SDD) Framework for Salesforce.", + "author": "Sumanth Yanamala", + "version": "1.0.0", + "download_url": "https://github.com/ysumanth06/spec-kit-sf/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/ysumanth06/spec-kit-sf", + "homepage": "https://ysumanth06.github.io/spec-kit-sf/", + "documentation": "https://ysumanth06.github.io/spec-kit-sf/introduction.html", + "changelog": "https://github.com/ysumanth06/spec-kit-sf/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0", + "tools": [ + { + "name": "sf", + "version": ">=2.0.0", + "required": true + }, + { + "name": "gh", + "version": ">=2.0.0", + "required": false + } + ] + }, + "provides": { + "commands": 18, + "hooks": 2 + }, + "tags": [ + "salesforce", + "enterprise", + "sdlc", + "apex", + "devops" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-13T22:11:30Z", + "updated_at": "2026-04-13T22:11:30Z" + }, "ship": { "name": "Ship Release Extension", "id": "ship", From c0152e4f3d4a550cd78d60f625dbfc85312bc8a6 Mon Sep 17 00:00:00 2001 From: Rafa Gomes <565337+0xrafasec@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:17:28 -0300 Subject: [PATCH 043/184] docs(catalog): add claude-ask-questions to community preset catalog (#2191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add claude-ask-questions preset for AskUserQuestion rendering Delivers the /speckit.clarify and /speckit.checklist AskUserQuestion integration as a stackable preset under presets/claude-ask-questions/ instead of modifying core templates or ClaudeIntegration. - presets/claude-ask-questions/preset.yml registers command overrides for speckit.clarify and speckit.checklist following the same pattern as the bundled lean preset. - Override commands replace the Markdown-table question-rendering blocks with AskUserQuestion instructions. Option | Description maps to {label, description} for clarify; Option | Candidate | Why It Matters maps to {label: Candidate, description: Why It Matters} for checklist. Recommended option is placed first with a "Recommended — " prefix; a final "Custom"/"Short" option preserves the free-form ≤5-word escape hatch. - Registered in presets/catalog.json as a bundled preset. Core templates, ClaudeIntegration, and the existing test suite are left untouched, so non-Claude agents and users who do not install this preset see no behavior change. Closes github/spec-kit#2181 Co-Authored-By: Claude Opus 4.6 * refactor: move claude-ask-questions preset to external repo Per maintainer feedback on #2191, presets should be hosted on the author's own GitHub repository and registered in catalog.community.json rather than bundled in spec-kit. Removes the bundled preset directory and its entry from the official catalog, and adds a community catalog entry pointing at the external repository and release archive. Co-Authored-By: Claude Opus 4.6 * docs(catalog): sync claude-ask-questions description with upstream preset * revert: keep presets/catalog.json updated_at unchanged No entries in the official catalog changed in this PR, so the timestamp bump was spurious. Addresses Copilot review feedback on #2191. --------- Co-authored-by: Claude Opus 4.6 --- presets/catalog.community.json | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/presets/catalog.community.json b/presets/catalog.community.json index b212037661..bc105e7486 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-09T08:00:00Z", + "updated_at": "2026-04-13T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "aide-in-place": { @@ -53,6 +53,33 @@ "spec-first" ] }, + "claude-ask-questions": { + "name": "Claude AskUserQuestion", + "id": "claude-ask-questions", + "version": "1.0.0", + "description": "Upgrades /speckit.clarify and /speckit.checklist on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question.", + "author": "0xrafasec", + "repository": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions", + "download_url": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions", + "documentation": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "templates": 0, + "commands": 2 + }, + "tags": [ + "claude", + "ask-user-question", + "clarify", + "checklist" + ], + "created_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-13T00:00:00Z" + }, "explicit-task-dependencies": { "name": "Explicit Task Dependencies", "id": "explicit-task-dependencies", From a00e6799182520cb8905eb59e61510753cbe981d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:11:56 -0500 Subject: [PATCH 044/184] Add workflow engine with catalog system (#2158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Add workflow engine with step registry, expression engine, catalog system, and CLI commands Agent-Logs-Url: https://github.com/github/spec-kit/sessions/72a7bb5d-071f-4d67-a507-7e1abae2384d Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Add comprehensive tests for workflow engine (94 tests) Agent-Logs-Url: https://github.com/github/spec-kit/sessions/72a7bb5d-071f-4d67-a507-7e1abae2384d Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Address review feedback: do-while condition preservation and URL scheme validation Agent-Logs-Url: https://github.com/github/spec-kit/sessions/72a7bb5d-071f-4d67-a507-7e1abae2384d Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Address review feedback, add CLI dispatch, interactive gates, and docs Review comments (7/7): - Add explanatory comment to empty except block - Implement workflow catalog download with cleanup on failure - Add input type coercion for number/boolean/enum - Fix example workflow to remove non-existent output references - Fix while_loop and if_then condition defaults (string 'false' → bool False) - Fix resume step index tracking with step_offset parameter CLI dispatch: - Add build_exec_args() and dispatch_command() to IntegrationBase - Override for Claude (skills: /speckit-specify), Gemini (-m flag), Codex (codex exec), Copilot (--agent speckit.specify) - CommandStep invokes installed commands by name via integration CLI - Add PromptStep for arbitrary inline prompts (10th step type) - Stream CLI output live to terminal (no silent blocking) - Remove timeout when streaming (user can Ctrl+C) - Ctrl+C saves state as PAUSED for clean resume Interactive gates: - Gate steps prompt [1] approve [2] reject in TTY - Fall back to PAUSED in non-interactive environments - Resume re-executes the gate for interactive prompting Documentation: - workflows/README.md — user guide - workflows/ARCHITECTURE.md — internals with Mermaid diagrams - workflows/PUBLISHING.md — catalog submission guide Tests: 94 → 122 workflow tests, 1362 total (all passing) * Fix ruff lint errors: unused imports, f-string placeholders, undefined name * Address second review: registry-backed validation, shell failures, loop/fan-out execution, URL validation - VALID_STEP_TYPES now queries STEP_REGISTRY dynamically - Shell step returns FAILED on non-zero exit code - Persist workflow YAML in run directory for reliable resume - Resume loads from run copy, falls back to installed workflow - Engine iterates while/do-while loops up to max_iterations - Engine expands fan-out per item with context.item - HTTPS URL validation for catalog workflow installs (HTTP allowed for localhost) - Fix catalog merge priority docstring (lower number wins) - Fix dispatch_command docstring (no build_exec_args_for_command) - Gate on_reject=retry pauses for re-prompt on resume - Update docs to 10 step types, add prompt step to tables and README * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Address third review: fan-out IDs, catalog guards, shell coercion, docs - Fan-out generates unique per-item step IDs and collects results - Catalog merge skips non-dict workflow entries (malformed data guard) - Shell step coerces run_cmd to str after expression evaluation - urlopen timeout=30 for catalog workflow installs - yaml.dump with sort_keys=False, allow_unicode=True for catalog configs - Document streaming timeout as intentionally unbounded (user Ctrl+C) - Document --allow-all-tools as required for non-interactive + future enhancement - Update test docstring and PUBLISHING.md to 10 step types with prompt * Validate final URL after redirects in catalog fetch urlopen follows redirects, so validate the response URL against the same HTTPS/localhost rules to prevent redirect-based downgrade attacks. * Address fourth review: filter arg eval, tags normalization, install redirect check - Filter arguments now evaluated via _evaluate_simple_expression() so default(42) returns int not string - Tags normalized: non-list/non-string values handled gracefully - Install URL redirect validation (same as catalog fetch) - Remove unused 'skipped' variable in catalog config parsing - Author 'github' → 'GitHub' in example workflow - Document nested step resume limitation (re-runs parent step) * Add explanatory comment to empty except ValueError block * Address fifth review: expression parsing, fan-out output, URL install, gate options - Move string literal parsing before operator detection in expressions so quoted strings with operators (e.g. 'a in b') are not mis-parsed - Fan-out: remove max_concurrency from persisted output, fix docstring to reflect sequential execution - workflow add: support URL sources with HTTPS/redirect validation, validate workflow ID is non-empty before writing files - Deduplicate local install logic via _validate_and_install_local() - Remove 'edit' gate option from speckit workflow (not implemented) * Add comments to empty except ValueError blocks in URL install * Address sixth review: operator precedence, fan_in cleanup, registry resilience, docs - Fix or/and operator precedence (or parsed first = lower precedence) - Restore context.fan_in after fan-in step completes - Catch JSONDecodeError in registry load for corrupted files - Replace print() with on_step_start callback (library-safe) - Gate validation warns when on_reject set but no reject option - Shell step: document shell=True security tradeoff - README: sdd-pipeline → speckit, parallel → sequential for fan-out - ARCHITECTURE.md: parallel → fan-out/fan-in in diagram * Address seventh review: string literal before pipe, type annotations, validate on install - Move string literal check above pipe filter parsing so 'a | b' works - Fix type annotations: input_values list[str] | None, run_id str | None - Run validate_workflow() before installing from local path/URL - Remove duplicate string literal check from expression parser * Address eighth review: fan-out namespaced IDs, early return, catalog validation - Fan-out per-item step IDs use _fanout_{step_id}_{base}_{idx} namespace to avoid collisions with user-defined step IDs - Early return after fan-out loop when state is paused/failed/aborted - Catalog installs parse + validate downloaded YAML before registering, using definition metadata instead of catalog entry for registry * Address ninth review: populate catalog, fix indentation, priority, README - Add speckit workflow entry to catalog.json so it's discoverable - Fix shell step output dict indentation - Catalog add_catalog priority derived from max existing + 1 - README Quick Start clarified with install + local file examples * Address tenth review: max_iterations validation, catalog config guard, version alignment - Validate max_iterations is int >= 1 in while and do-while steps - Guard add_catalog against corrupted config (non-dict/non-list) - Align speckit_version requirement to >=0.6.1 (current package version) - Fan-out template validation uses separate seen_ids set to avoid false duplication errors with user-defined step IDs * Address eleventh review: command step fails without CLI, ID mismatch warning, state persistence - Command step returns FAILED when CLI not installed (was silent COMPLETED) - Catalog install warns on workflow ID vs catalog key mismatch - Engine persists state.save() before returning on unknown step type - Update tests to expect FAILED for command steps without CLI - Integration tests use shell steps for CLI-independent execution * Address twelfth review: type annotations, version examples, streaming docs, requires - Fix workflow_search type annotations (str | None) - PUBLISHING.md: speckit_version >=0.15.0 → >=0.6.1 - Document that exit_code is captured and referenceable by later steps - Mark requires as declared-but-not-enforced (planned enhancement) - Note full stdout/stderr capture as planned enhancement * Enforce catalog key matches workflow ID (fail instead of warn) * Bundle speckit workflow: auto-install during specify init - Add workflows/speckit to pyproject.toml force-include for wheel builds - Add _locate_bundled_workflow() helper (mirrors _locate_bundled_extension) - Auto-install speckit workflow during specify init (after git extension) - Update all integration file inventory tests to expect workflow files * Address fourteenth review: prompt fails without CLI, resolved step data, fan-out normalization - PromptStep returns FAILED when CLI not installed (was silent COMPLETED) - Engine step_data prefers resolved values from step output - Fan-out normalizes output.results=[] for empty item lists - subprocess.run inherits stdout/stderr (no explicit sys.stdout) - Registry tests use issubset for extensibility * Address fifteenth review: fan_in docstring, gate defaults, validation guards, reserved prefix - FanInStep docstring: aggregate-only, no blocking semantics - FanInStep: guard output_config as dict, handle None - Gate validate: use same default options as execute - Validate inputs is dict and steps is list before iterating - Reserve _fanout_ prefix in step ID validation - PUBLISHING.md: remove unenforced checklist items, add _fanout_ note * Address sixteenth review: docs regex, fan_in try/finally, hyphenated dot-path keys - PUBLISHING.md: update ID regex docs to match implementation (single-char OK) - FanInStep: wrap expression evaluation in try/finally for context.fan_in - Expression dot-path: allow hyphens in keys before list index (e.g. run-tests[0]) * Make speckit workflow integration-agnostic, document Copilot CLI requirement - Workflow integration selectable via input (default: claude) - Each command step uses {{ inputs.integration }} instead of hardcoded copilot - Copilot docstring documents CLI requirement for workflow dispatch - Added install_url for Copilot CLI docs * Address seventeenth review: project checks, catalog robustness - Add .specify/ project check to workflow run/resume/status/search/info - remove_catalog validates config shape (dict + list) before indexing - _fetch_single_catalog validates response is a dict - _get_merged_workflows raises when all catalogs fail to fetch - add_catalog guards against non-dict catalog entries in config * Address eighteenth review: condition coercion, gate abort result, while default, cache guard, resume log - evaluate_condition treats plain 'false'/'true' strings as booleans - Gate abort returns StepResult(FAILED) instead of raising exception so step output is persisted in state for inspection - while_loop max_iterations optional (default 10), validation aligned - Catalog cache fallback catches invalid JSON gracefully - resume() appends workflow_finished log entry like execute() * Address nineteenth review: allow-all-tools opt-in, empty catalogs, abort dead code, while docstring - --allow-all-tools controlled by SPECKIT_ALLOW_ALL_TOOLS env var (default: 1) Set to 0 to disable automatic tool approval for Copilot CLI - Empty catalogs list falls back to built-in defaults (not an error) - Remove unreachable WorkflowAbortError catches from execute/resume (gate abort now returns StepResult(FAILED) instead of raising) - while_loop docstring updated: max_iterations is optional (default 10) * Address twentieth review: gate abort maps to ABORTED status, do-while max_iterations optional - Engine detects output.aborted from gate step and sets RunStatus.ABORTED (was unreachable — gate abort returned FAILED but status was always FAILED) - do-while max_iterations now optional (default 10), aligned with while_loop - do-while docstring and validation updated accordingly * Coerce default_options to dict, align bundled workflow ID regex with validator * Gate validates string options, prompt uses resolved integration, loop normalizes max_iterations * Use parentId:childId convention for nested step IDs - Fan-out per-item IDs use parentId:templateId:index (e.g. parallel:impl:0) - Reserve ':' in user step IDs (validation rejects) - Replaces _fanout_ prefix with cleaner namespacing - Expressions like {{ steps.parallel:impl:0.output.file }} work naturally * Validate workflow version is semantic versioning (X.Y.Z) * Schema version validation, strict semver, load_workflow docstring, preserve max_concurrency - Validate schema_version is '1.0' (reject unknown future schemas) - Strict semver regex: ^\d+\.\d+\.\d+$ (rejects 1.0.0beta etc.) - load_workflow docstring: 'parsed' not 'validated' - Keep max_concurrency in fan-out output (was dropped) - do_while docstring: engine re-evaluates step_config condition - ARCHITECTURE.md: document nested resume limitation * Path traversal prevention, loop step ID namespacing - RunState validates run_id is alphanumeric+hyphens (no path separators) - workflow_add validates catalog source doesn't escape workflows_dir - Loop iterations namespace nested step IDs as parentId:childId:iteration so multiple iterations don't overwrite each other in context/state --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- pyproject.toml | 2 + src/specify_cli/__init__.py | 719 +++++++ src/specify_cli/integrations/base.py | 176 ++ .../integrations/codex/__init__.py | 15 + .../integrations/copilot/__init__.py | 104 +- src/specify_cli/workflows/__init__.py | 68 + src/specify_cli/workflows/base.py | 132 ++ src/specify_cli/workflows/catalog.py | 540 +++++ src/specify_cli/workflows/engine.py | 778 +++++++ src/specify_cli/workflows/expressions.py | 300 +++ src/specify_cli/workflows/steps/__init__.py | 1 + .../workflows/steps/command/__init__.py | 155 ++ .../workflows/steps/do_while/__init__.py | 61 + .../workflows/steps/fan_in/__init__.py | 61 + .../workflows/steps/fan_out/__init__.py | 58 + .../workflows/steps/gate/__init__.py | 121 ++ .../workflows/steps/if_then/__init__.py | 55 + .../workflows/steps/prompt/__init__.py | 156 ++ .../workflows/steps/shell/__init__.py | 75 + .../workflows/steps/switch/__init__.py | 70 + .../workflows/steps/while_loop/__init__.py | 68 + .../test_integration_base_markdown.py | 3 + .../test_integration_base_skills.py | 5 + .../test_integration_base_toml.py | 3 + .../test_integration_base_yaml.py | 3 + .../integrations/test_integration_copilot.py | 4 + .../integrations/test_integration_generic.py | 4 + tests/test_workflows.py | 1803 +++++++++++++++++ workflows/ARCHITECTURE.md | 211 ++ workflows/PUBLISHING.md | 285 +++ workflows/README.md | 339 ++++ workflows/catalog.community.json | 6 + workflows/catalog.json | 16 + workflows/speckit/workflow.yml | 63 + 34 files changed, 6458 insertions(+), 2 deletions(-) create mode 100644 src/specify_cli/workflows/__init__.py create mode 100644 src/specify_cli/workflows/base.py create mode 100644 src/specify_cli/workflows/catalog.py create mode 100644 src/specify_cli/workflows/engine.py create mode 100644 src/specify_cli/workflows/expressions.py create mode 100644 src/specify_cli/workflows/steps/__init__.py create mode 100644 src/specify_cli/workflows/steps/command/__init__.py create mode 100644 src/specify_cli/workflows/steps/do_while/__init__.py create mode 100644 src/specify_cli/workflows/steps/fan_in/__init__.py create mode 100644 src/specify_cli/workflows/steps/fan_out/__init__.py create mode 100644 src/specify_cli/workflows/steps/gate/__init__.py create mode 100644 src/specify_cli/workflows/steps/if_then/__init__.py create mode 100644 src/specify_cli/workflows/steps/prompt/__init__.py create mode 100644 src/specify_cli/workflows/steps/shell/__init__.py create mode 100644 src/specify_cli/workflows/steps/switch/__init__.py create mode 100644 src/specify_cli/workflows/steps/while_loop/__init__.py create mode 100644 tests/test_workflows.py create mode 100644 workflows/ARCHITECTURE.md create mode 100644 workflows/PUBLISHING.md create mode 100644 workflows/README.md create mode 100644 workflows/catalog.community.json create mode 100644 workflows/catalog.json create mode 100644 workflows/speckit/workflow.yml diff --git a/pyproject.toml b/pyproject.toml index 7253358f78..db53f2cb58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ packages = ["src/specify_cli"] "scripts/powershell" = "specify_cli/core_pack/scripts/powershell" # Bundled extensions (installable via `specify extension add `) "extensions/git" = "specify_cli/core_pack/extensions/git" +# Bundled workflows (auto-installed during `specify init`) +"workflows/speckit" = "specify_cli/core_pack/workflows/speckit" # Bundled presets (installable via `specify preset add ` or `specify init --preset `) "presets/lean" = "specify_cli/core_pack/presets/lean" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0bbf42ad5a..c33281e2b4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -621,6 +621,31 @@ def _locate_bundled_extension(extension_id: str) -> Path | None: return None +def _locate_bundled_workflow(workflow_id: str) -> Path | None: + """Return the path to a bundled workflow directory, or None. + + Checks the wheel's core_pack first, then falls back to the + source-checkout ``workflows//`` directory. + """ + import re as _re + if not _re.match(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', workflow_id): + return None + + core = _locate_core_pack() + if core is not None: + candidate = core / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate + + # Source-checkout / editable install: look relative to repo root + repo_root = Path(__file__).parent.parent.parent + candidate = repo_root / "workflows" / workflow_id + if (candidate / "workflow.yml").is_file(): + return candidate + + return None + + def _locate_bundled_preset(preset_id: str) -> Path | None: """Return the path to a bundled preset, or None. @@ -1159,6 +1184,7 @@ def init( ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), ("git", "Install git extension"), + ("workflow", "Install bundled workflow"), ("final", "Finalize"), ]: tracker.add(key, label) @@ -1262,6 +1288,37 @@ def init( else: tracker.skip("git", "--no-git flag") + # Install bundled speckit workflow + try: + bundled_wf = _locate_bundled_workflow("speckit") + if bundled_wf: + from .workflows.catalog import WorkflowRegistry + from .workflows.engine import WorkflowDefinition + wf_registry = WorkflowRegistry(project_path) + if wf_registry.is_installed("speckit"): + tracker.complete("workflow", "already installed") + else: + import shutil as _shutil + dest_wf = project_path / ".specify" / "workflows" / "speckit" + dest_wf.mkdir(parents=True, exist_ok=True) + _shutil.copy2( + bundled_wf / "workflow.yml", + dest_wf / "workflow.yml", + ) + definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml") + wf_registry.add("speckit", { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": "bundled", + }) + tracker.complete("workflow", "speckit installed") + else: + tracker.skip("workflow", "bundled workflow not found") + except Exception as wf_err: + sanitized_wf = str(wf_err).replace('\n', ' ').strip() + tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") + # Fix permissions after all installs (scripts + extensions) ensure_executable_scripts(project_path, tracker=tracker) @@ -4136,6 +4193,668 @@ def extension_set_priority( console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") +# ===== Workflow Commands ===== + +workflow_app = typer.Typer( + name="workflow", + help="Manage and run automation workflows", + add_completion=False, +) +app.add_typer(workflow_app, name="workflow") + +workflow_catalog_app = typer.Typer( + name="catalog", + help="Manage workflow catalogs", + add_completion=False, +) +workflow_app.add_typer(workflow_catalog_app, name="catalog") + + +@workflow_app.command("run") +def workflow_run( + source: str = typer.Argument(..., help="Workflow ID or YAML file path"), + input_values: list[str] | None = typer.Option( + None, "--input", "-i", help="Input values as key=value pairs" + ), +): + """Run a workflow from an installed ID or local YAML path.""" + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") + + try: + definition = engine.load_workflow(source) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Workflow not found: {source}") + raise typer.Exit(1) + except ValueError as exc: + console.print(f"[red]Error:[/red] Invalid workflow: {exc}") + raise typer.Exit(1) + + # Validate + errors = engine.validate(definition) + if errors: + console.print("[red]Workflow validation failed:[/red]") + for err in errors: + console.print(f" • {err}") + raise typer.Exit(1) + + # Parse inputs + inputs: dict[str, Any] = {} + if input_values: + for kv in input_values: + if "=" not in kv: + console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)") + raise typer.Exit(1) + key, _, value = kv.partition("=") + inputs[key.strip()] = value.strip() + + console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})") + console.print(f"[dim]Version: {definition.version}[/dim]\n") + + try: + state = engine.execute(definition, inputs) + except ValueError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + except Exception as exc: + console.print(f"[red]Workflow failed:[/red] {exc}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + console.print(f"[dim]Run ID: {state.run_id}[/dim]") + + if state.status.value == "paused": + console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]") + + +@workflow_app.command("resume") +def workflow_resume( + run_id: str = typer.Argument(..., help="Run ID to resume"), +): + """Resume a paused or failed workflow run.""" + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") + + try: + state = engine.resume(run_id) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Run not found: {run_id}") + raise typer.Exit(1) + except ValueError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + except Exception as exc: + console.print(f"[red]Resume failed:[/red] {exc}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + } + color = status_colors.get(state.status.value, "white") + console.print(f"\n[{color}]Status: {state.status.value}[/{color}]") + + +@workflow_app.command("status") +def workflow_status( + run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"), +): + """Show workflow run status.""" + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + engine = WorkflowEngine(project_root) + + if run_id: + try: + from .workflows.engine import RunState + state = RunState.load(run_id, project_root) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Run not found: {run_id}") + raise typer.Exit(1) + + status_colors = { + "completed": "green", + "paused": "yellow", + "failed": "red", + "aborted": "red", + "running": "blue", + "created": "dim", + } + color = status_colors.get(state.status.value, "white") + + console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]") + console.print(f" Workflow: {state.workflow_id}") + console.print(f" Status: [{color}]{state.status.value}[/{color}]") + console.print(f" Created: {state.created_at}") + console.print(f" Updated: {state.updated_at}") + + if state.current_step_id: + console.print(f" Current: {state.current_step_id}") + + if state.step_results: + console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]") + for step_id, step_data in state.step_results.items(): + s = step_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white") + console.print(f" [{sc}]●[/{sc}] {step_id}: {s}") + else: + runs = engine.list_runs() + if not runs: + console.print("[yellow]No workflow runs found.[/yellow]") + return + + console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n") + for run_data in runs: + s = run_data.get("status", "unknown") + sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white") + console.print( + f" [{sc}]●[/{sc}] {run_data['run_id']} " + f"{run_data.get('workflow_id', '?')} " + f"[{sc}]{s}[/{sc}] " + f"[dim]{run_data.get('updated_at', '?')}[/dim]" + ) + + +@workflow_app.command("list") +def workflow_list(): + """List installed workflows.""" + from .workflows.catalog import WorkflowRegistry + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + installed = registry.list() + + if not installed: + console.print("[yellow]No workflows installed.[/yellow]") + console.print("\nInstall a workflow with:") + console.print(" [cyan]specify workflow add [/cyan]") + return + + console.print("\n[bold cyan]Installed Workflows:[/bold cyan]\n") + for wf_id, wf_data in installed.items(): + console.print(f" [bold]{wf_data.get('name', wf_id)}[/bold] ({wf_id}) v{wf_data.get('version', '?')}") + desc = wf_data.get("description", "") + if desc: + console.print(f" {desc}") + console.print() + + +@workflow_app.command("add") +def workflow_add( + source: str = typer.Argument(..., help="Workflow ID, URL, or local path"), +): + """Install a workflow from catalog, URL, or local path.""" + from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + from .workflows.engine import WorkflowDefinition + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + workflows_dir = project_root / ".specify" / "workflows" + + def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: + """Validate and install a workflow from a local YAML file.""" + try: + definition = WorkflowDefinition.from_yaml(yaml_path) + except (ValueError, yaml.YAMLError) as exc: + console.print(f"[red]Error:[/red] Invalid workflow YAML: {exc}") + raise typer.Exit(1) + if not definition.id or not definition.id.strip(): + console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'") + raise typer.Exit(1) + + from .workflows.engine import validate_workflow + errors = validate_workflow(definition) + if errors: + console.print("[red]Error:[/red] Workflow validation failed:") + for err in errors: + console.print(f" \u2022 {err}") + raise typer.Exit(1) + + dest_dir = workflows_dir / definition.id + dest_dir.mkdir(parents=True, exist_ok=True) + import shutil + shutil.copy2(yaml_path, dest_dir / "workflow.yml") + registry.add(definition.id, { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": source_label, + }) + console.print(f"[green]✓[/green] Workflow '{definition.name}' ({definition.id}) installed") + + # Try as URL (http/https) + if source.startswith("http://") or source.startswith("https://"): + from ipaddress import ip_address + from urllib.parse import urlparse + from urllib.request import urlopen # noqa: S310 + + parsed_src = urlparse(source) + src_host = parsed_src.hostname or "" + src_loopback = src_host == "localhost" + if not src_loopback: + try: + src_loopback = ip_address(src_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a DNS name); keep default non-loopback. + pass + if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback): + console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.") + raise typer.Exit(1) + + import tempfile + try: + with urlopen(source, timeout=30) as resp: # noqa: S310 + final_url = resp.geturl() + final_parsed = urlparse(final_url) + final_host = final_parsed.hostname or "" + final_lb = final_host == "localhost" + if not final_lb: + try: + final_lb = ip_address(final_host).is_loopback + except ValueError: + # Redirect host is not an IP literal; keep loopback as determined above. + pass + if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb): + console.print(f"[red]Error:[/red] URL redirected to non-HTTPS: {final_url}") + raise typer.Exit(1) + with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp: + tmp.write(resp.read()) + tmp_path = Path(tmp.name) + except typer.Exit: + raise + except Exception as exc: + console.print(f"[red]Error:[/red] Failed to download workflow: {exc}") + raise typer.Exit(1) + try: + _validate_and_install_local(tmp_path, source) + finally: + tmp_path.unlink(missing_ok=True) + return + + # Try as a local file/directory + source_path = Path(source) + if source_path.exists(): + if source_path.is_file() and source_path.suffix in (".yml", ".yaml"): + _validate_and_install_local(source_path, str(source_path)) + return + elif source_path.is_dir(): + wf_file = source_path / "workflow.yml" + if not wf_file.exists(): + console.print(f"[red]Error:[/red] No workflow.yml found in {source}") + raise typer.Exit(1) + _validate_and_install_local(wf_file, str(source_path)) + return + + # Try from catalog + catalog = WorkflowCatalog(project_root) + try: + info = catalog.get_workflow_info(source) + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not info: + console.print(f"[red]Error:[/red] Workflow '{source}' not found in catalog") + raise typer.Exit(1) + + if not info.get("_install_allowed", True): + console.print(f"[yellow]Warning:[/yellow] Workflow '{source}' is from a discovery-only catalog") + console.print("Direct installation is not enabled for this catalog source.") + raise typer.Exit(1) + + workflow_url = info.get("url") + if not workflow_url: + console.print(f"[red]Error:[/red] Workflow '{source}' does not have an install URL in the catalog") + raise typer.Exit(1) + + # Validate URL scheme (HTTPS required, HTTP allowed for localhost only) + from ipaddress import ip_address + from urllib.parse import urlparse + + parsed_url = urlparse(workflow_url) + url_host = parsed_url.hostname or "" + is_loopback = False + if url_host == "localhost": + is_loopback = True + else: + try: + is_loopback = ip_address(url_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback): + console.print( + f"[red]Error:[/red] Workflow '{source}' has an invalid install URL. " + "Only HTTPS URLs are allowed, except HTTP for localhost/loopback." + ) + raise typer.Exit(1) + + workflow_dir = workflows_dir / source + # Validate that source is a safe directory name (no path traversal) + try: + workflow_dir.resolve().relative_to(workflows_dir.resolve()) + except ValueError: + console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}") + raise typer.Exit(1) + workflow_file = workflow_dir / "workflow.yml" + + try: + from urllib.request import urlopen # noqa: S310 — URL comes from catalog + + workflow_dir.mkdir(parents=True, exist_ok=True) + with urlopen(workflow_url, timeout=30) as response: # noqa: S310 + # Validate final URL after redirects + final_url = response.geturl() + final_parsed = urlparse(final_url) + final_host = final_parsed.hostname or "" + final_loopback = final_host == "localhost" + if not final_loopback: + try: + final_loopback = ip_address(final_host).is_loopback + except ValueError: + # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback. + pass + if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback): + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print( + f"[red]Error:[/red] Workflow '{source}' redirected to non-HTTPS URL: {final_url}" + ) + raise typer.Exit(1) + workflow_file.write_bytes(response.read()) + except Exception as exc: + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print(f"[red]Error:[/red] Failed to install workflow '{source}' from catalog: {exc}") + raise typer.Exit(1) + + # Validate the downloaded workflow before registering + try: + definition = WorkflowDefinition.from_yaml(workflow_file) + except (ValueError, yaml.YAMLError) as exc: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print(f"[red]Error:[/red] Downloaded workflow is invalid: {exc}") + raise typer.Exit(1) + + from .workflows.engine import validate_workflow + errors = validate_workflow(definition) + if errors: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print("[red]Error:[/red] Downloaded workflow validation failed:") + for err in errors: + console.print(f" \u2022 {err}") + raise typer.Exit(1) + + # Enforce that the workflow's internal ID matches the catalog key + if definition.id and definition.id != source: + import shutil + shutil.rmtree(workflow_dir, ignore_errors=True) + console.print( + f"[red]Error:[/red] Workflow ID in YAML ({definition.id!r}) " + f"does not match catalog key ({source!r}). " + f"The catalog entry may be misconfigured." + ) + raise typer.Exit(1) + + registry.add(source, { + "name": definition.name or info.get("name", source), + "version": definition.version or info.get("version", "0.0.0"), + "description": definition.description or info.get("description", ""), + "source": "catalog", + "catalog_name": info.get("_catalog_name", ""), + "url": workflow_url, + }) + console.print(f"[green]✓[/green] Workflow '{info.get('name', source)}' installed from catalog") + + +@workflow_app.command("remove") +def workflow_remove( + workflow_id: str = typer.Argument(..., help="Workflow ID to uninstall"), +): + """Uninstall a workflow.""" + from .workflows.catalog import WorkflowRegistry + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + registry = WorkflowRegistry(project_root) + + if not registry.is_installed(workflow_id): + console.print(f"[red]Error:[/red] Workflow '{workflow_id}' is not installed") + raise typer.Exit(1) + + # Remove workflow files + workflow_dir = project_root / ".specify" / "workflows" / workflow_id + if workflow_dir.exists(): + import shutil + shutil.rmtree(workflow_dir) + + registry.remove(workflow_id) + console.print(f"[green]✓[/green] Workflow '{workflow_id}' removed") + + +@workflow_app.command("search") +def workflow_search( + query: str | None = typer.Argument(None, help="Search query"), + tag: str | None = typer.Option(None, "--tag", help="Filter by tag"), +): + """Search workflow catalogs.""" + from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + catalog = WorkflowCatalog(project_root) + + try: + results = catalog.search(query=query, tag=tag) + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if not results: + console.print("[yellow]No workflows found.[/yellow]") + return + + console.print(f"\n[bold cyan]Workflows ({len(results)}):[/bold cyan]\n") + for wf in results: + console.print(f" [bold]{wf.get('name', wf.get('id', '?'))}[/bold] ({wf.get('id', '?')}) v{wf.get('version', '?')}") + desc = wf.get("description", "") + if desc: + console.print(f" {desc}") + tags = wf.get("tags", []) + if tags: + console.print(f" [dim]Tags: {', '.join(tags)}[/dim]") + console.print() + + +@workflow_app.command("info") +def workflow_info( + workflow_id: str = typer.Argument(..., help="Workflow ID"), +): + """Show workflow details and step graph.""" + from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError + from .workflows.engine import WorkflowEngine + + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + # Check installed first + registry = WorkflowRegistry(project_root) + installed = registry.get(workflow_id) + + engine = WorkflowEngine(project_root) + + definition = None + try: + definition = engine.load_workflow(workflow_id) + except FileNotFoundError: + # Local workflow definition not found on disk; fall back to + # catalog/registry lookup below. + pass + + if definition: + console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})") + console.print(f" Version: {definition.version}") + if definition.author: + console.print(f" Author: {definition.author}") + if definition.description: + console.print(f" Description: {definition.description}") + if definition.default_integration: + console.print(f" Integration: {definition.default_integration}") + if installed: + console.print(" [green]Installed[/green]") + + if definition.inputs: + console.print("\n [bold]Inputs:[/bold]") + for name, inp in definition.inputs.items(): + if isinstance(inp, dict): + req = "required" if inp.get("required") else "optional" + console.print(f" {name} ({inp.get('type', 'string')}) — {req}") + + if definition.steps: + console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]") + for step in definition.steps: + stype = step.get("type", "command") + console.print(f" → {step.get('id', '?')} [{stype}]") + return + + # Try catalog + catalog = WorkflowCatalog(project_root) + try: + info = catalog.get_workflow_info(workflow_id) + except WorkflowCatalogError: + info = None + + if info: + console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})") + console.print(f" Version: {info.get('version', '?')}") + if info.get("description"): + console.print(f" Description: {info['description']}") + if info.get("tags"): + console.print(f" Tags: {', '.join(info['tags'])}") + console.print(" [yellow]Not installed[/yellow]") + else: + console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found") + raise typer.Exit(1) + + +@workflow_catalog_app.command("list") +def workflow_catalog_list(): + """List configured workflow catalog sources.""" + from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError + + project_root = Path.cwd() + catalog = WorkflowCatalog(project_root) + + try: + configs = catalog.get_catalog_configs() + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n") + for i, cfg in enumerate(configs): + install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]" + console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}") + console.print(f" {cfg['url']}") + if cfg.get("description"): + console.print(f" [dim]{cfg['description']}[/dim]") + console.print() + + +@workflow_catalog_app.command("add") +def workflow_catalog_add( + url: str = typer.Argument(..., help="Catalog URL to add"), + name: str = typer.Option(None, "--name", help="Catalog name"), +): + """Add a workflow catalog source.""" + from .workflows.catalog import WorkflowCatalog, WorkflowValidationError + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + catalog = WorkflowCatalog(project_root) + try: + catalog.add_catalog(url, name) + except WorkflowValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source added: {url}") + + +@workflow_catalog_app.command("remove") +def workflow_catalog_remove( + index: int = typer.Argument(..., help="Catalog index to remove (from 'catalog list')"), +): + """Remove a workflow catalog source by index.""" + from .workflows.catalog import WorkflowCatalog, WorkflowValidationError + + project_root = Path.cwd() + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + raise typer.Exit(1) + + catalog = WorkflowCatalog(project_root) + try: + removed_name = catalog.remove_catalog(index) + except WorkflowValidationError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") + + def main(): app() diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 87eca9d3bf..26501e623f 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -91,6 +91,123 @@ def options(cls) -> list[IntegrationOption]: """Return options this integration accepts. Default: none.""" return [] + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build CLI arguments for non-interactive execution. + + Returns a list of command-line tokens that will execute *prompt* + non-interactively using this integration's CLI tool, or ``None`` + if the integration does not support CLI dispatch. + + Subclasses for CLI-based integrations should override this. + """ + return None + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Build the native slash-command invocation for a Spec Kit command. + + The CLI tools discover and execute commands from installed files + on disk. This method builds the invocation string the CLI + expects — e.g. ``"/speckit.specify my-feature"`` for markdown + agents or ``"/speckit-specify my-feature"`` for skills agents. + + *command_name* may be a full dotted name like + ``"speckit.specify"`` or a bare stem like ``"specify"``. + """ + stem = command_name + if "." in stem: + stem = stem.rsplit(".", 1)[-1] + + invocation = f"/speckit.{stem}" + if args: + invocation = f"{invocation} {args}" + return invocation + + def dispatch_command( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + model: str | None = None, + timeout: int = 600, + stream: bool = True, + ) -> dict[str, Any]: + """Dispatch a Spec Kit command through this integration's CLI. + + By default this builds a slash-command invocation with + ``build_command_invocation()`` and passes that prompt to + ``build_exec_args()`` to construct the CLI command line. + Integrations with custom dispatch behavior can override + ``build_command_invocation()``, ``build_exec_args()``, or + ``dispatch_command()`` directly. + + When *stream* is ``True`` (the default), stdout and stderr are + piped directly to the terminal so the user sees live output. + When ``False``, output is captured and returned in the dict. + + Returns a dict with ``exit_code``, ``stdout``, and ``stderr``. + Raises ``NotImplementedError`` if the integration does not + support CLI dispatch. + """ + import subprocess + + prompt = self.build_command_invocation(command_name, args) + # When streaming to the terminal, request text output so the + # user sees readable output instead of raw JSONL events. + exec_args = self.build_exec_args( + prompt, model=model, output_json=not stream + ) + + if exec_args is None: + msg = ( + f"Integration {self.key!r} does not support CLI dispatch. " + f"Override build_exec_args() to enable it." + ) + raise NotImplementedError(msg) + + cwd = str(project_root) if project_root else None + + if stream: + # No timeout when streaming — the user sees live output and + # can Ctrl+C at any time. The timeout parameter is only + # applied in the captured (non-streaming) branch below. + try: + result = subprocess.run( + exec_args, + text=True, + cwd=cwd, + ) + except KeyboardInterrupt: + return { + "exit_code": 130, + "stdout": "", + "stderr": "Interrupted by user", + } + return { + "exit_code": result.returncode, + "stdout": "", + "stderr": "", + } + + result = subprocess.run( + exec_args, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + # -- Primitives — building blocks for setup() ------------------------- def shared_commands_dir(self) -> Path | None: @@ -466,6 +583,22 @@ class MarkdownIntegration(IntegrationBase): integration-specific scripts (``update-context.sh`` / ``.ps1``). """ + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self.key, "-p", prompt] + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + def setup( self, project_root: Path, @@ -534,6 +667,22 @@ class TomlIntegration(IntegrationBase): TOML format (``description`` key + ``prompt`` multiline string). """ + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self.key, "-p", prompt] + if model: + args.extend(["-m", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + def command_filename(self, template_name: str) -> str: """TOML commands use ``.toml`` extension.""" return f"speckit.{template_name}.toml" @@ -908,6 +1057,22 @@ class SkillsIntegration(IntegrationBase): ``speckit-/SKILL.md`` file with skills-oriented frontmatter. """ + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + if not self.config or not self.config.get("requires_cli"): + return None + args = [self.key, "-p", prompt] + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + def skills_dest(self, project_root: Path) -> Path: """Return the absolute path to the skills output directory. @@ -926,6 +1091,17 @@ def skills_dest(self, project_root: Path) -> Path: subdir = self.config.get("commands_subdir", "skills") return project_root / folder / subdir + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Skills use ``/speckit-`` (hyphenated directory name).""" + stem = command_name + if "." in stem: + stem = stem.rsplit(".", 1)[-1] + + invocation = f"/speckit-{stem}" + if args: + invocation = f"{invocation} {args}" + return invocation + def setup( self, project_root: Path, diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index f6415f9bb2..b3b509b654 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -28,6 +28,21 @@ class CodexIntegration(SkillsIntegration): } context_file = "AGENTS.md" + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + # Codex uses ``codex exec "prompt"`` for non-interactive mode. + args: list[str] = ["codex", "exec", prompt] + if model: + args.extend(["--model", model]) + if output_json: + args.append("--json") + return args + @classmethod def options(cls) -> list[IntegrationOption]: return [ diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 036f2e1db7..e389138a84 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -19,14 +19,19 @@ class CopilotIntegration(IntegrationBase): - """Integration for GitHub Copilot in VS Code.""" + """Integration for GitHub Copilot (VS Code IDE + CLI). + + The IDE integration (``requires_cli: False``) installs ``.agent.md`` + command files. Workflow dispatch additionally requires the + ``copilot`` CLI to be installed separately. + """ key = "copilot" config = { "name": "GitHub Copilot", "folder": ".github/", "commands_subdir": "agents", - "install_url": None, + "install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli", "requires_cli": False, } registrar_config = { @@ -37,6 +42,101 @@ class CopilotIntegration(IntegrationBase): } context_file = ".github/copilot-instructions.md" + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + # GitHub Copilot CLI uses ``copilot -p "prompt"`` for + # non-interactive mode. --allow-all-tools is required for the + # agent to perform file edits and shell commands. Controlled + # by SPECKIT_ALLOW_ALL_TOOLS env var (default: enabled). + import os + args = ["copilot", "-p", prompt] + if os.environ.get("SPECKIT_ALLOW_ALL_TOOLS", "1") != "0": + args.append("--allow-all-tools") + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Copilot agents are not slash-commands — just return the args as prompt.""" + return args or "" + + def dispatch_command( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + model: str | None = None, + timeout: int = 600, + stream: bool = True, + ) -> dict[str, Any]: + """Dispatch via ``--agent speckit.`` instead of slash-commands. + + Copilot ``.agent.md`` files are agents, not skills. The CLI + selects them with ``--agent `` and the prompt is just + the user's arguments. + """ + import subprocess + + stem = command_name + if "." in stem: + stem = stem.rsplit(".", 1)[-1] + agent_name = f"speckit.{stem}" + + prompt = args or "" + import os + cli_args = [ + "copilot", "-p", prompt, + "--agent", agent_name, + ] + if os.environ.get("SPECKIT_ALLOW_ALL_TOOLS", "1") != "0": + cli_args.append("--allow-all-tools") + if model: + cli_args.extend(["--model", model]) + if not stream: + cli_args.extend(["--output-format", "json"]) + + cwd = str(project_root) if project_root else None + + if stream: + try: + result = subprocess.run( + cli_args, + text=True, + cwd=cwd, + ) + except KeyboardInterrupt: + return { + "exit_code": 130, + "stdout": "", + "stderr": "Interrupted by user", + } + return { + "exit_code": result.returncode, + "stdout": "", + "stderr": "", + } + + result = subprocess.run( + cli_args, + capture_output=True, + text=True, + cwd=cwd, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + def command_filename(self, template_name: str) -> str: """Copilot commands use ``.agent.md`` extension.""" return f"speckit.{template_name}.agent.md" diff --git a/src/specify_cli/workflows/__init__.py b/src/specify_cli/workflows/__init__.py new file mode 100644 index 0000000000..13782f620b --- /dev/null +++ b/src/specify_cli/workflows/__init__.py @@ -0,0 +1,68 @@ +"""Workflow engine for multi-step, resumable automation workflows. + +Provides: +- ``StepBase`` — abstract base every step type must implement. +- ``StepContext`` — execution context passed to each step. +- ``StepResult`` — return value from step execution. +- ``STEP_REGISTRY`` — maps ``type_key`` to ``StepBase`` subclass instances. +- ``WorkflowEngine`` — orchestrator that loads, validates, and executes + workflow YAML definitions. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .base import StepBase + +# Maps step type_key → StepBase instance. +STEP_REGISTRY: dict[str, StepBase] = {} + + +def _register_step(step: StepBase) -> None: + """Register a step type instance in the global registry. + + Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates. + """ + key = step.type_key + if not key: + raise ValueError("Cannot register step type with an empty type_key.") + if key in STEP_REGISTRY: + raise KeyError(f"Step type with key {key!r} is already registered.") + STEP_REGISTRY[key] = step + + +def get_step_type(type_key: str) -> StepBase | None: + """Return the step type for *type_key*, or ``None`` if not registered.""" + return STEP_REGISTRY.get(type_key) + + +# -- Register built-in step types ---------------------------------------- + +def _register_builtin_steps() -> None: + """Register all built-in step types.""" + from .steps.command import CommandStep + from .steps.do_while import DoWhileStep + from .steps.fan_in import FanInStep + from .steps.fan_out import FanOutStep + from .steps.gate import GateStep + from .steps.if_then import IfThenStep + from .steps.prompt import PromptStep + from .steps.shell import ShellStep + from .steps.switch import SwitchStep + from .steps.while_loop import WhileStep + + _register_step(CommandStep()) + _register_step(DoWhileStep()) + _register_step(FanInStep()) + _register_step(FanOutStep()) + _register_step(GateStep()) + _register_step(IfThenStep()) + _register_step(PromptStep()) + _register_step(ShellStep()) + _register_step(SwitchStep()) + _register_step(WhileStep()) + + +_register_builtin_steps() diff --git a/src/specify_cli/workflows/base.py b/src/specify_cli/workflows/base.py new file mode 100644 index 0000000000..b144ca903d --- /dev/null +++ b/src/specify_cli/workflows/base.py @@ -0,0 +1,132 @@ +"""Base classes for workflow step types. + +Provides: +- ``StepBase`` — abstract base every step type must implement. +- ``StepContext`` — execution context passed to each step. +- ``StepResult`` — return value from step execution. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class StepStatus(str, Enum): + """Status of a step execution.""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + SKIPPED = "skipped" + PAUSED = "paused" + + +class RunStatus(str, Enum): + """Status of a workflow run.""" + + CREATED = "created" + RUNNING = "running" + PAUSED = "paused" + COMPLETED = "completed" + FAILED = "failed" + ABORTED = "aborted" + + +@dataclass +class StepContext: + """Execution context passed to each step. + + Contains everything the step needs to resolve expressions, dispatch + commands, and record results. + """ + + #: Resolved workflow inputs (from user prompts / defaults). + inputs: dict[str, Any] = field(default_factory=dict) + + #: Accumulated step results keyed by step ID. + #: Each entry is ``{"integration": ..., "model": ..., "options": ..., + #: "input": ..., "output": ...}``. + steps: dict[str, dict[str, Any]] = field(default_factory=dict) + + #: Current fan-out item (set only inside fan-out iterations). + item: Any = None + + #: Fan-in aggregated results (set only for fan-in steps). + fan_in: dict[str, Any] = field(default_factory=dict) + + #: Workflow-level default integration key. + default_integration: str | None = None + + #: Workflow-level default model. + default_model: str | None = None + + #: Workflow-level default options. + default_options: dict[str, Any] = field(default_factory=dict) + + #: Project root path. + project_root: str | None = None + + #: Current run ID. + run_id: str | None = None + + +@dataclass +class StepResult: + """Return value from a step execution.""" + + #: Step status. + status: StepStatus = StepStatus.COMPLETED + + #: Output data (stored as ``steps..output``). + output: dict[str, Any] = field(default_factory=dict) + + #: Nested steps to execute (for control-flow steps like if/then). + next_steps: list[dict[str, Any]] = field(default_factory=list) + + #: Error message if step failed. + error: str | None = None + + +class StepBase(ABC): + """Abstract base class for workflow step types. + + Every step type — built-in or extension-provided — implements this + interface and registers in ``STEP_REGISTRY``. + """ + + #: Matches the ``type:`` value in workflow YAML. + type_key: str = "" + + @abstractmethod + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + """Execute the step with the given config and context. + + Parameters + ---------- + config: + The step configuration from workflow YAML. + context: + The execution context with inputs, accumulated step results, etc. + + Returns + ------- + StepResult with status, output data, and optional nested steps. + """ + + def validate(self, config: dict[str, Any]) -> list[str]: + """Validate step configuration and return a list of error messages. + + An empty list means the configuration is valid. + """ + errors: list[str] = [] + if "id" not in config: + errors.append("Step is missing required 'id' field.") + return errors + + def can_resume(self, state: dict[str, Any]) -> bool: + """Return whether this step can be resumed from the given state.""" + return True diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py new file mode 100644 index 0000000000..da5c60b5c8 --- /dev/null +++ b/src/specify_cli/workflows/catalog.py @@ -0,0 +1,540 @@ +"""Workflow catalog — discovery, install, and management of workflows. + +Mirrors the existing extension/preset catalog pattern with: +- Multi-catalog stack (env var → project → user → built-in) +- SHA256-hashed per-URL caching with 1-hour TTL +- Workflow registry for installed workflow tracking +- Search across all configured catalog sources +""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class WorkflowCatalogError(Exception): + """Base error for workflow catalog operations.""" + + +class WorkflowValidationError(WorkflowCatalogError): + """Validation error for catalog config or workflow data.""" + + +# --------------------------------------------------------------------------- +# CatalogEntry +# --------------------------------------------------------------------------- + + +@dataclass +class WorkflowCatalogEntry: + """Represents a single catalog source in the catalog stack.""" + + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +# --------------------------------------------------------------------------- +# WorkflowRegistry +# --------------------------------------------------------------------------- + + +class WorkflowRegistry: + """Manages the registry of installed workflows. + + Tracks installed workflows and their metadata in + ``.specify/workflows/workflow-registry.json``. + """ + + REGISTRY_FILE = "workflow-registry.json" + SCHEMA_VERSION = "1.0" + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.workflows_dir = project_root / ".specify" / "workflows" + self.registry_path = self.workflows_dir / self.REGISTRY_FILE + self.data = self._load() + + def _load(self) -> dict[str, Any]: + """Load registry from disk or create default.""" + if self.registry_path.exists(): + try: + with open(self.registry_path, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, ValueError): + # Corrupted registry file — reset to default + return {"schema_version": self.SCHEMA_VERSION, "workflows": {}} + return {"schema_version": self.SCHEMA_VERSION, "workflows": {}} + + def save(self) -> None: + """Persist registry to disk.""" + self.workflows_dir.mkdir(parents=True, exist_ok=True) + with open(self.registry_path, "w", encoding="utf-8") as f: + json.dump(self.data, f, indent=2) + + def add(self, workflow_id: str, metadata: dict[str, Any]) -> None: + """Add or update an installed workflow entry.""" + from datetime import datetime, timezone + + existing = self.data["workflows"].get(workflow_id, {}) + metadata["installed_at"] = existing.get( + "installed_at", datetime.now(timezone.utc).isoformat() + ) + metadata["updated_at"] = datetime.now(timezone.utc).isoformat() + self.data["workflows"][workflow_id] = metadata + self.save() + + def remove(self, workflow_id: str) -> bool: + """Remove an installed workflow entry. Returns True if found.""" + if workflow_id in self.data["workflows"]: + del self.data["workflows"][workflow_id] + self.save() + return True + return False + + def get(self, workflow_id: str) -> dict[str, Any] | None: + """Get metadata for an installed workflow.""" + return self.data["workflows"].get(workflow_id) + + def list(self) -> dict[str, dict[str, Any]]: + """Return all installed workflows.""" + return dict(self.data["workflows"]) + + def is_installed(self, workflow_id: str) -> bool: + """Check if a workflow is installed.""" + return workflow_id in self.data["workflows"] + + +# --------------------------------------------------------------------------- +# WorkflowCatalog +# --------------------------------------------------------------------------- + + +class WorkflowCatalog: + """Manages workflow catalog fetching, caching, and searching. + + Resolution order for catalog sources: + 1. ``SPECKIT_WORKFLOW_CATALOG_URL`` env var (overrides all) + 2. Project-level ``.specify/workflow-catalogs.yml`` + 3. User-level ``~/.specify/workflow-catalogs.yml`` + 4. Built-in defaults (official + community) + """ + + DEFAULT_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "workflows/catalog.json" + ) + COMMUNITY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/" + "workflows/catalog.community.json" + ) + CACHE_DURATION = 3600 # 1 hour + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.workflows_dir = project_root / ".specify" / "workflows" + self.cache_dir = self.workflows_dir / ".cache" + + # -- Catalog resolution ----------------------------------------------- + + def _validate_catalog_url(self, url: str) -> None: + """Validate that a catalog URL uses HTTPS (localhost HTTP allowed).""" + from urllib.parse import urlparse + + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise WorkflowValidationError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise WorkflowValidationError( + "Catalog URL must be a valid URL with a host." + ) + + def _load_catalog_config( + self, config_path: Path + ) -> list[WorkflowCatalogEntry] | None: + """Load catalog stack configuration from a YAML file.""" + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise WorkflowValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) + catalogs_data = data.get("catalogs", []) + if not catalogs_data: + # Empty catalogs list (e.g. after removing last entry) + # is valid — fall back to built-in defaults. + return None + if not isinstance(catalogs_data, list): + raise WorkflowValidationError( + f"Invalid catalog config: 'catalogs' must be a list, " + f"got {type(catalogs_data).__name__}" + ) + + entries: list[WorkflowCatalogEntry] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise WorkflowValidationError( + f"Invalid catalog entry at index {idx}: " + f"expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): + raise WorkflowValidationError( + f"Invalid priority for catalog " + f"'{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ( + "true", + "yes", + "1", + ) + else: + install_allowed = bool(raw_install) + entries.append( + WorkflowCatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + ) + ) + entries.sort(key=lambda e: e.priority) + if not entries: + raise WorkflowValidationError( + f"Catalog config {config_path} contains {len(catalogs_data)} " + f"entries but none have valid URLs." + ) + return entries + + def get_active_catalogs(self) -> list[WorkflowCatalogEntry]: + """Get the ordered list of active catalogs.""" + # 1. Environment variable override + env_url = os.environ.get("SPECKIT_WORKFLOW_CATALOG_URL", "").strip() + if env_url: + self._validate_catalog_url(env_url) + return [ + WorkflowCatalogEntry( + url=env_url, + name="env-override", + priority=1, + install_allowed=True, + description="From SPECKIT_WORKFLOW_CATALOG_URL", + ) + ] + + # 2. Project-level config + project_config = self.project_root / ".specify" / "workflow-catalogs.yml" + project_entries = self._load_catalog_config(project_config) + if project_entries is not None: + return project_entries + + # 3. User-level config + home = Path.home() + user_config = home / ".specify" / "workflow-catalogs.yml" + user_entries = self._load_catalog_config(user_config) + if user_entries is not None: + return user_entries + + # 4. Built-in defaults + return [ + WorkflowCatalogEntry( + url=self.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + description="Official workflows", + ), + WorkflowCatalogEntry( + url=self.COMMUNITY_CATALOG_URL, + name="community", + priority=2, + install_allowed=False, + description="Community-contributed workflows (discovery only)", + ), + ] + + # -- Caching ---------------------------------------------------------- + + def _get_cache_paths(self, url: str) -> tuple[Path, Path]: + """Get cache file paths for a URL (hash-based).""" + url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] + cache_file = self.cache_dir / f"workflow-catalog-{url_hash}.json" + meta_file = self.cache_dir / f"workflow-catalog-{url_hash}-meta.json" + return cache_file, meta_file + + def _is_url_cache_valid(self, url: str) -> bool: + """Check if cached data for a URL is still fresh.""" + _, meta_file = self._get_cache_paths(url) + if not meta_file.exists(): + return False + try: + with open(meta_file, encoding="utf-8") as f: + meta = json.load(f) + fetched_at = meta.get("fetched_at", 0) + return (time.time() - fetched_at) < self.CACHE_DURATION + except (json.JSONDecodeError, OSError): + return False + + def _fetch_single_catalog( + self, entry: WorkflowCatalogEntry, force_refresh: bool = False + ) -> dict[str, Any]: + """Fetch a single catalog, using cache when possible.""" + cache_file, meta_file = self._get_cache_paths(entry.url) + + if not force_refresh and self._is_url_cache_valid(entry.url): + try: + with open(cache_file, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + pass + + # Fetch from URL — validate scheme before opening and after redirects + from urllib.parse import urlparse + from urllib.request import urlopen + + def _validate_catalog_url(url: str) -> None: + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not ( + parsed.scheme == "http" and is_localhost + ): + raise WorkflowCatalogError( + f"Refusing to fetch catalog from non-HTTPS URL: {url}" + ) + + _validate_catalog_url(entry.url) + + try: + with urlopen(entry.url, timeout=30) as resp: # noqa: S310 + _validate_catalog_url(resp.geturl()) + data = json.loads(resp.read().decode("utf-8")) + except Exception as exc: + # Fall back to cache if available + if cache_file.exists(): + try: + with open(cache_file, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, ValueError, OSError): + pass + raise WorkflowCatalogError( + f"Failed to fetch catalog from {entry.url}: {exc}" + ) from exc + + if not isinstance(data, dict): + raise WorkflowCatalogError( + f"Catalog from {entry.url} is not a valid JSON object." + ) + + # Write cache + self.cache_dir.mkdir(parents=True, exist_ok=True) + with open(cache_file, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + with open(meta_file, "w", encoding="utf-8") as f: + json.dump({"url": entry.url, "fetched_at": time.time()}, f) + + return data + + def _get_merged_workflows( + self, force_refresh: bool = False + ) -> dict[str, dict[str, Any]]: + """Merge workflows from all active catalogs (lower priority number wins).""" + catalogs = self.get_active_catalogs() + merged: dict[str, dict[str, Any]] = {} + fetch_errors = 0 + + # Process later/higher-numbered entries first so earlier/lower-numbered + # entries overwrite them on workflow ID conflicts. + for entry in reversed(catalogs): + try: + data = self._fetch_single_catalog(entry, force_refresh) + except WorkflowCatalogError: + fetch_errors += 1 + continue + workflows = data.get("workflows", {}) + # Handle both dict and list formats + if isinstance(workflows, dict): + for wf_id, wf_data in workflows.items(): + if not isinstance(wf_data, dict): + continue + wf_data["_catalog_name"] = entry.name + wf_data["_install_allowed"] = entry.install_allowed + merged[wf_id] = wf_data + elif isinstance(workflows, list): + for wf_data in workflows: + if not isinstance(wf_data, dict): + continue + wf_id = wf_data.get("id", "") + if wf_id: + wf_data["_catalog_name"] = entry.name + wf_data["_install_allowed"] = entry.install_allowed + merged[wf_id] = wf_data + if fetch_errors == len(catalogs) and catalogs: + raise WorkflowCatalogError( + "All configured catalogs failed to fetch." + ) + return merged + + # -- Public API ------------------------------------------------------- + + def search( + self, + query: str | None = None, + tag: str | None = None, + ) -> list[dict[str, Any]]: + """Search workflows across all configured catalogs.""" + merged = self._get_merged_workflows() + results: list[dict[str, Any]] = [] + + for wf_id, wf_data in merged.items(): + wf_data.setdefault("id", wf_id) + if query: + q = query.lower() + searchable = " ".join( + [ + wf_data.get("name", ""), + wf_data.get("description", ""), + wf_data.get("id", ""), + ] + ).lower() + if q not in searchable: + continue + if tag: + raw_tags = wf_data.get("tags", []) + tags = raw_tags if isinstance(raw_tags, list) else [] + normalized_tags = [t.lower() for t in tags if isinstance(t, str)] + if tag.lower() not in normalized_tags: + continue + results.append(wf_data) + return results + + def get_workflow_info(self, workflow_id: str) -> dict[str, Any] | None: + """Get details for a specific workflow from the catalog.""" + merged = self._get_merged_workflows() + wf = merged.get(workflow_id) + if wf: + wf.setdefault("id", workflow_id) + return wf + + def get_catalog_configs(self) -> list[dict[str, Any]]: + """Return current catalog configuration as a list of dicts.""" + entries = self.get_active_catalogs() + return [ + { + "name": e.name, + "url": e.url, + "priority": e.priority, + "install_allowed": e.install_allowed, + "description": e.description, + } + for e in entries + ] + + def add_catalog(self, url: str, name: str | None = None) -> None: + """Add a catalog source to the project-level config.""" + self._validate_catalog_url(url) + config_path = self.project_root / ".specify" / "workflow-catalogs.yml" + + data: dict[str, Any] = {"catalogs": []} + if config_path.exists(): + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise WorkflowValidationError( + "Catalog config file is corrupted (expected a mapping)." + ) + data = raw + + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise WorkflowValidationError( + "Catalog config 'catalogs' must be a list." + ) + # Check for duplicate URL (guard against non-dict entries) + for cat in catalogs: + if isinstance(cat, dict) and cat.get("url") == url: + raise WorkflowValidationError( + f"Catalog URL already configured: {url}" + ) + + # Derive priority from the highest existing priority + 1 + max_priority = max( + (cat.get("priority", 0) for cat in catalogs if isinstance(cat, dict)), + default=0, + ) + catalogs.append( + { + "name": name or f"catalog-{len(catalogs) + 1}", + "url": url, + "priority": max_priority + 1, + "install_allowed": True, + "description": "", + } + ) + data["catalogs"] = catalogs + + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + def remove_catalog(self, index: int) -> str: + """Remove a catalog source by index (0-based). Returns the removed name.""" + config_path = self.project_root / ".specify" / "workflow-catalogs.yml" + if not config_path.exists(): + raise WorkflowValidationError("No catalog config file found.") + + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise WorkflowValidationError( + "Catalog config file is corrupted (expected a mapping)." + ) + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise WorkflowValidationError( + "Catalog config 'catalogs' must be a list." + ) + + if index < 0 or index >= len(catalogs): + raise WorkflowValidationError( + f"Catalog index {index} out of range (0-{len(catalogs) - 1})." + ) + + removed = catalogs.pop(index) + data["catalogs"] = catalogs + + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True) + + if isinstance(removed, dict): + return removed.get("name", f"catalog-{index + 1}") + return f"catalog-{index + 1}" diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py new file mode 100644 index 0000000000..d6a73bbeb0 --- /dev/null +++ b/src/specify_cli/workflows/engine.py @@ -0,0 +1,778 @@ +"""Workflow engine — loads, validates, and executes workflow YAML definitions. + +The engine is the orchestrator that: +- Parses workflow YAML definitions +- Validates step configurations and requirements +- Executes steps sequentially, dispatching to the correct step type +- Manages state persistence for resume capability +- Handles control flow (branching, loops, fan-out/fan-in) +""" + +from __future__ import annotations + +import json +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +from .base import RunStatus, StepContext, StepResult, StepStatus + + +# -- Workflow Definition -------------------------------------------------- + + +class WorkflowDefinition: + """Parsed and validated workflow YAML definition.""" + + def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> None: + self.data = data + self.source_path = source_path + + workflow = data.get("workflow", {}) + self.id: str = workflow.get("id", "") + self.name: str = workflow.get("name", "") + self.version: str = workflow.get("version", "0.0.0") + self.author: str = workflow.get("author", "") + self.description: str = workflow.get("description", "") + self.schema_version: str = data.get("schema_version", "1.0") + + # Defaults + self.default_integration: str | None = workflow.get("integration") + self.default_model: str | None = workflow.get("model") + self.default_options: dict[str, Any] = workflow.get("options") or {} + if not isinstance(self.default_options, dict): + self.default_options = {} + + # Requirements (declared but not yet enforced at runtime; + # enforcement is a planned enhancement) + self.requires: dict[str, Any] = data.get("requires", {}) + + # Inputs + self.inputs: dict[str, Any] = data.get("inputs", {}) + + # Steps + self.steps: list[dict[str, Any]] = data.get("steps", []) + + @classmethod + def from_yaml(cls, path: Path) -> WorkflowDefinition: + """Load a workflow definition from a YAML file.""" + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + msg = f"Workflow YAML must be a mapping, got {type(data).__name__}." + raise ValueError(msg) + return cls(data, source_path=path) + + @classmethod + def from_string(cls, content: str) -> WorkflowDefinition: + """Load a workflow definition from a YAML string.""" + data = yaml.safe_load(content) + if not isinstance(data, dict): + msg = f"Workflow YAML must be a mapping, got {type(data).__name__}." + raise ValueError(msg) + return cls(data) + + +# -- Workflow Validation -------------------------------------------------- + +# ID format: lowercase alphanumeric with hyphens +_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$") + +# Valid step types (matching STEP_REGISTRY keys) +def _get_valid_step_types() -> set[str]: + """Return valid step types from the registry, with a built-in fallback.""" + from . import STEP_REGISTRY + if STEP_REGISTRY: + return set(STEP_REGISTRY.keys()) + return { + "command", "shell", "prompt", "gate", "if", + "switch", "while", "do-while", "fan-out", "fan-in", + } + + +def validate_workflow(definition: WorkflowDefinition) -> list[str]: + """Validate a workflow definition and return a list of error messages. + + An empty list means the workflow is valid. + """ + errors: list[str] = [] + + # -- Schema version --------------------------------------------------- + if definition.schema_version not in ("1.0", "1"): + errors.append( + f"Unsupported schema_version {definition.schema_version!r}. " + f"Expected '1.0'." + ) + + # -- Top-level fields ------------------------------------------------- + if not definition.id: + errors.append("Workflow is missing 'workflow.id'.") + elif not _ID_PATTERN.match(definition.id): + errors.append( + f"Workflow ID {definition.id!r} must be lowercase alphanumeric " + f"with hyphens." + ) + + if not definition.name: + errors.append("Workflow is missing 'workflow.name'.") + + if not definition.version: + errors.append("Workflow is missing 'workflow.version'.") + elif not re.match(r"^\d+\.\d+\.\d+$", definition.version): + errors.append( + f"Workflow version {definition.version!r} is not valid " + f"semantic versioning (expected X.Y.Z)." + ) + + # -- Inputs ----------------------------------------------------------- + if not isinstance(definition.inputs, dict): + errors.append("'inputs' must be a mapping (or omitted).") + else: + for input_name, input_def in definition.inputs.items(): + if not isinstance(input_def, dict): + errors.append(f"Input {input_name!r} must be a mapping.") + continue + input_type = input_def.get("type") + if input_type and input_type not in ("string", "number", "boolean"): + errors.append( + f"Input {input_name!r} has invalid type {input_type!r}. " + f"Must be 'string', 'number', or 'boolean'." + ) + + # -- Steps ------------------------------------------------------------ + if not isinstance(definition.steps, list): + errors.append("'steps' must be a list.") + return errors + if not definition.steps: + errors.append("Workflow has no steps defined.") + + seen_ids: set[str] = set() + _validate_steps(definition.steps, seen_ids, errors) + + return errors + + +def _validate_steps( + steps: list[dict[str, Any]], + seen_ids: set[str], + errors: list[str], +) -> None: + """Recursively validate a list of steps.""" + from . import STEP_REGISTRY + + for step_config in steps: + if not isinstance(step_config, dict): + errors.append(f"Step must be a mapping, got {type(step_config).__name__}.") + continue + + step_id = step_config.get("id") + if not step_id: + errors.append("Step is missing 'id' field.") + continue + + if ":" in step_id: + errors.append( + f"Step ID {step_id!r} contains ':' which is reserved " + f"for engine-generated nested IDs (parentId:childId)." + ) + + if step_id in seen_ids: + errors.append(f"Duplicate step ID {step_id!r}.") + seen_ids.add(step_id) + + # Determine step type + step_type = step_config.get("type", "command") + if step_type not in _get_valid_step_types(): + errors.append( + f"Step {step_id!r} has invalid type {step_type!r}." + ) + continue + + # Delegate to step-specific validation + step_impl = STEP_REGISTRY.get(step_type) + if step_impl: + step_errors = step_impl.validate(step_config) + errors.extend(step_errors) + + # Recursively validate nested steps + for nested_key in ("then", "else", "steps"): + nested = step_config.get(nested_key) + if isinstance(nested, list): + _validate_steps(nested, seen_ids, errors) + + # Validate switch cases + cases = step_config.get("cases") + if isinstance(cases, dict): + for _case_key, case_steps in cases.items(): + if isinstance(case_steps, list): + _validate_steps(case_steps, seen_ids, errors) + + # Validate switch default + default = step_config.get("default") + if isinstance(default, list): + _validate_steps(default, seen_ids, errors) + + # Validate fan-out nested step (template — not added to seen_ids + # since the engine generates parentId:templateId:index at runtime) + fan_step = step_config.get("step") + if isinstance(fan_step, dict): + fan_errors: list[str] = [] + _validate_steps([fan_step], set(), fan_errors) + errors.extend(fan_errors) + + +# -- Run State Persistence ------------------------------------------------ + + +class RunState: + """Manages workflow run state for persistence and resume.""" + + def __init__( + self, + run_id: str | None = None, + workflow_id: str = "", + project_root: Path | None = None, + ) -> None: + self.run_id = run_id or str(uuid.uuid4())[:8] + if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id): + msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only." + raise ValueError(msg) + self.workflow_id = workflow_id + self.project_root = project_root or Path(".") + self.status = RunStatus.CREATED + self.current_step_index = 0 + self.current_step_id: str | None = None + self.step_results: dict[str, dict[str, Any]] = {} + self.inputs: dict[str, Any] = {} + self.created_at = datetime.now(timezone.utc).isoformat() + self.updated_at = self.created_at + self.log_entries: list[dict[str, Any]] = [] + + @property + def runs_dir(self) -> Path: + return self.project_root / ".specify" / "workflows" / "runs" / self.run_id + + def save(self) -> None: + """Persist current state to disk.""" + self.updated_at = datetime.now(timezone.utc).isoformat() + runs_dir = self.runs_dir + runs_dir.mkdir(parents=True, exist_ok=True) + + state_data = { + "run_id": self.run_id, + "workflow_id": self.workflow_id, + "status": self.status.value, + "current_step_index": self.current_step_index, + "current_step_id": self.current_step_id, + "step_results": self.step_results, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + with open(runs_dir / "state.json", "w", encoding="utf-8") as f: + json.dump(state_data, f, indent=2) + + inputs_data = {"inputs": self.inputs} + with open(runs_dir / "inputs.json", "w", encoding="utf-8") as f: + json.dump(inputs_data, f, indent=2) + + @classmethod + def load(cls, run_id: str, project_root: Path) -> RunState: + """Load a run state from disk.""" + runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id + state_path = runs_dir / "state.json" + if not state_path.exists(): + msg = f"Run state not found: {state_path}" + raise FileNotFoundError(msg) + + with open(state_path, encoding="utf-8") as f: + state_data = json.load(f) + + state = cls( + run_id=state_data["run_id"], + workflow_id=state_data["workflow_id"], + project_root=project_root, + ) + state.status = RunStatus(state_data["status"]) + state.current_step_index = state_data.get("current_step_index", 0) + state.current_step_id = state_data.get("current_step_id") + state.step_results = state_data.get("step_results", {}) + state.created_at = state_data.get("created_at", "") + state.updated_at = state_data.get("updated_at", "") + + inputs_path = runs_dir / "inputs.json" + if inputs_path.exists(): + with open(inputs_path, encoding="utf-8") as f: + inputs_data = json.load(f) + state.inputs = inputs_data.get("inputs", {}) + + return state + + def append_log(self, entry: dict[str, Any]) -> None: + """Append a log entry to the run log.""" + entry["timestamp"] = datetime.now(timezone.utc).isoformat() + self.log_entries.append(entry) + + runs_dir = self.runs_dir + runs_dir.mkdir(parents=True, exist_ok=True) + with open(runs_dir / "log.jsonl", "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +# -- Workflow Engine ------------------------------------------------------ + + +class WorkflowEngine: + """Orchestrator that loads, validates, and executes workflow definitions.""" + + def __init__(self, project_root: Path | None = None) -> None: + self.project_root = project_root or Path(".") + self.on_step_start: Any = None # Callable[[str, str], None] | None + + def load_workflow(self, source: str | Path) -> WorkflowDefinition: + """Load a workflow from an installed ID or a local YAML path. + + Parameters + ---------- + source: + Either a workflow ID (looked up in the installed workflows + directory) or a path to a YAML file. + + Returns + ------- + A parsed ``WorkflowDefinition`` (not yet validated; call + ``validate_workflow()`` or ``engine.validate()`` separately). + + Raises + ------ + FileNotFoundError: + If the workflow file cannot be found. + ValueError: + If the workflow YAML is invalid. + """ + path = Path(source) + + # Try as a direct file path first + if path.suffix in (".yml", ".yaml") and path.exists(): + return WorkflowDefinition.from_yaml(path) + + # Try as an installed workflow ID + installed_path = ( + self.project_root + / ".specify" + / "workflows" + / str(source) + / "workflow.yml" + ) + if installed_path.exists(): + return WorkflowDefinition.from_yaml(installed_path) + + msg = f"Workflow not found: {source}" + raise FileNotFoundError(msg) + + def validate(self, definition: WorkflowDefinition) -> list[str]: + """Validate a workflow definition.""" + return validate_workflow(definition) + + def execute( + self, + definition: WorkflowDefinition, + inputs: dict[str, Any] | None = None, + run_id: str | None = None, + ) -> RunState: + """Execute a workflow definition. + + Parameters + ---------- + definition: + The validated workflow definition. + inputs: + User-provided input values. + run_id: + Optional run ID (auto-generated if not provided). + + Returns + ------- + The final ``RunState`` after execution completes (or pauses). + """ + from . import STEP_REGISTRY + + state = RunState( + run_id=run_id, + workflow_id=definition.id, + project_root=self.project_root, + ) + + # Persist a copy of the workflow definition so resume can + # reload it even if the original source is no longer available + # (e.g. a local YAML path that was moved or deleted). + run_dir = self.project_root / ".specify" / "workflows" / "runs" / state.run_id + run_dir.mkdir(parents=True, exist_ok=True) + workflow_copy = run_dir / "workflow.yml" + import yaml + with open(workflow_copy, "w", encoding="utf-8") as f: + yaml.safe_dump(definition.data, f, sort_keys=False) + + # Resolve inputs + resolved_inputs = self._resolve_inputs(definition, inputs or {}) + state.inputs = resolved_inputs + state.status = RunStatus.RUNNING + state.save() + + context = StepContext( + inputs=resolved_inputs, + default_integration=definition.default_integration, + default_model=definition.default_model, + default_options=definition.default_options, + project_root=str(self.project_root), + run_id=state.run_id, + ) + + # Execute steps + try: + self._execute_steps(definition.steps, context, state, STEP_REGISTRY) + except KeyboardInterrupt: + state.status = RunStatus.PAUSED + state.append_log({"event": "workflow_interrupted"}) + state.save() + return state + except Exception as exc: + state.status = RunStatus.FAILED + state.append_log({"event": "workflow_failed", "error": str(exc)}) + state.save() + raise + + if state.status == RunStatus.RUNNING: + state.status = RunStatus.COMPLETED + state.append_log({"event": "workflow_finished", "status": state.status.value}) + state.save() + return state + + def resume(self, run_id: str) -> RunState: + """Resume a paused or failed workflow run.""" + state = RunState.load(run_id, self.project_root) + if state.status not in (RunStatus.PAUSED, RunStatus.FAILED): + msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}." + raise ValueError(msg) + + # Load the workflow definition — try the persisted copy in the + # run directory first so resume works even if the original + # source (e.g. a local YAML path) is no longer available. + run_dir = self.project_root / ".specify" / "workflows" / "runs" / run_id + run_copy = run_dir / "workflow.yml" + if run_copy.exists(): + definition = WorkflowDefinition.from_yaml(run_copy) + else: + definition = self.load_workflow(state.workflow_id) + + # Restore context + context = StepContext( + inputs=state.inputs, + steps=state.step_results, + default_integration=definition.default_integration, + default_model=definition.default_model, + default_options=definition.default_options, + project_root=str(self.project_root), + run_id=state.run_id, + ) + + from . import STEP_REGISTRY + + state.status = RunStatus.RUNNING + state.save() + + # Resume from the current step — re-execute it so gates + # can prompt interactively again. + remaining_steps = definition.steps[state.current_step_index :] + step_offset = state.current_step_index + + try: + self._execute_steps( + remaining_steps, context, state, STEP_REGISTRY, + step_offset=step_offset, + ) + except KeyboardInterrupt: + state.status = RunStatus.PAUSED + state.append_log({"event": "workflow_interrupted"}) + state.save() + return state + except Exception as exc: + state.status = RunStatus.FAILED + state.append_log({"event": "resume_failed", "error": str(exc)}) + state.save() + raise + + if state.status == RunStatus.RUNNING: + state.status = RunStatus.COMPLETED + state.append_log({"event": "workflow_finished", "status": state.status.value}) + state.save() + return state + + def _execute_steps( + self, + steps: list[dict[str, Any]], + context: StepContext, + state: RunState, + registry: dict[str, Any], + *, + step_offset: int = 0, + ) -> None: + """Execute a list of steps sequentially.""" + for i, step_config in enumerate(steps): + step_id = step_config.get("id", f"step-{i}") + step_type = step_config.get("type", "command") + + state.current_step_id = step_id + if step_offset >= 0: + state.current_step_index = step_offset + i + state.save() + + state.append_log( + {"event": "step_started", "step_id": step_id, "type": step_type} + ) + + # Log progress — use the engine's on_step_start callback if set, + # otherwise stay silent (library-safe default). + label = step_config.get("command", "") or step_type + if self.on_step_start is not None: + self.on_step_start(step_id, label) + + step_impl = registry.get(step_type) + if not step_impl: + state.status = RunStatus.FAILED + state.append_log( + { + "event": "step_failed", + "step_id": step_id, + "error": f"Unknown step type: {step_type!r}", + } + ) + state.save() + return + + result: StepResult = step_impl.execute(step_config, context) + + # Record step results — prefer resolved values from step output + step_data = { + "integration": result.output.get("integration") + or step_config.get("integration") + or context.default_integration, + "model": result.output.get("model") + or step_config.get("model") + or context.default_model, + "options": result.output.get("options") + or step_config.get("options", {}), + "input": result.output.get("input") + or step_config.get("input", {}), + "output": result.output, + "status": result.status.value, + } + context.steps[step_id] = step_data + state.step_results[step_id] = step_data + + state.append_log( + { + "event": "step_completed", + "step_id": step_id, + "status": result.status.value, + } + ) + + # Handle gate pauses + if result.status == StepStatus.PAUSED: + state.status = RunStatus.PAUSED + state.save() + return + + # Handle failures + if result.status == StepStatus.FAILED: + # Gate abort (output.aborted) maps to ABORTED status + if result.output.get("aborted"): + state.status = RunStatus.ABORTED + state.append_log( + { + "event": "workflow_aborted", + "step_id": step_id, + } + ) + else: + state.status = RunStatus.FAILED + state.append_log( + { + "event": "step_failed", + "step_id": step_id, + "error": result.error, + } + ) + state.save() + return + + # Execute nested steps (from control flow) + # NOTE: Nested steps run with step_offset=-1 so they don't + # update current_step_index. If a nested step pauses, + # resume will re-run the parent step and its nested body. + # A step-path stack for exact nested resume is a future + # enhancement. + if result.next_steps: + self._execute_steps( + result.next_steps, context, state, registry, + step_offset=-1, + ) + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + return + + # Loop iteration: while/do-while re-evaluate after body + if step_type in ("while", "do-while"): + from .expressions import evaluate_condition + + max_iters = step_config.get("max_iterations") + if not isinstance(max_iters, int) or max_iters < 1: + max_iters = 10 + condition = step_config.get("condition", False) + for _loop_iter in range(max_iters - 1): + if not evaluate_condition(condition, context): + break + # Namespace nested step IDs per iteration + iter_steps = [] + for ns in result.next_steps: + ns_copy = dict(ns) + if "id" in ns_copy: + ns_copy["id"] = f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}" + iter_steps.append(ns_copy) + self._execute_steps( + iter_steps, context, state, registry, + step_offset=-1, + ) + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + return + + # Fan-out: execute nested step template per item with unique IDs + if step_type == "fan-out": + items = result.output.get("items", []) + template = result.output.get("step_template", {}) + if template and items: + fan_out_results = [] + for item_idx, item_val in enumerate(result.output["items"]): + context.item = item_val + # Per-item ID: parentId:templateId:index + item_step = dict(template) + base_id = item_step.get("id", "item") + item_step["id"] = f"{step_id}:{base_id}:{item_idx}" + self._execute_steps( + [item_step], context, state, registry, + step_offset=-1, + ) + # Collect per-item result for fan-in + item_result = context.steps.get(item_step["id"], {}) + fan_out_results.append(item_result.get("output", {})) + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + break + context.item = None + # Preserve original output and add collected results + fan_out_output = dict(result.output) + fan_out_output["results"] = fan_out_results + context.steps[step_id]["output"] = fan_out_output + state.step_results[step_id]["output"] = fan_out_output + if state.status in ( + RunStatus.PAUSED, + RunStatus.FAILED, + RunStatus.ABORTED, + ): + return + else: + # Empty items or no template — normalize output + result.output["results"] = [] + context.steps[step_id]["output"] = result.output + state.step_results[step_id]["output"] = result.output + + def _resolve_inputs( + self, + definition: WorkflowDefinition, + provided: dict[str, Any], + ) -> dict[str, Any]: + """Resolve workflow inputs against definitions and provided values.""" + resolved: dict[str, Any] = {} + for name, input_def in definition.inputs.items(): + if not isinstance(input_def, dict): + continue + if name in provided: + resolved[name] = self._coerce_input( + name, provided[name], input_def + ) + elif "default" in input_def: + resolved[name] = input_def["default"] + elif input_def.get("required", False): + msg = f"Required input {name!r} not provided." + raise ValueError(msg) + return resolved + + @staticmethod + def _coerce_input( + name: str, value: Any, input_def: dict[str, Any] + ) -> Any: + """Coerce a provided input value to the declared type.""" + input_type = input_def.get("type", "string") + enum_values = input_def.get("enum") + + if input_type == "number": + try: + value = float(value) + if value == int(value): + value = int(value) + except (ValueError, TypeError): + msg = f"Input {name!r} expected a number, got {value!r}." + raise ValueError(msg) from None + elif input_type == "boolean": + if isinstance(value, str): + if value.lower() in ("true", "1", "yes"): + value = True + elif value.lower() in ("false", "0", "no"): + value = False + else: + msg = f"Input {name!r} expected a boolean, got {value!r}." + raise ValueError(msg) + + if enum_values is not None and value not in enum_values: + msg = ( + f"Input {name!r} value {value!r} not in allowed " + f"values: {enum_values}." + ) + raise ValueError(msg) + + return value + + def list_runs(self) -> list[dict[str, Any]]: + """List all workflow runs in the project.""" + runs_dir = self.project_root / ".specify" / "workflows" / "runs" + if not runs_dir.exists(): + return [] + + runs: list[dict[str, Any]] = [] + for run_dir in sorted(runs_dir.iterdir()): + if not run_dir.is_dir(): + continue + state_path = run_dir / "state.json" + if state_path.exists(): + with open(state_path, encoding="utf-8") as f: + state_data = json.load(f) + runs.append(state_data) + return runs + + +class WorkflowAbortError(Exception): + """Raised when a workflow is aborted (e.g., gate rejection).""" diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py new file mode 100644 index 0000000000..3a2d3fbf2a --- /dev/null +++ b/src/specify_cli/workflows/expressions.py @@ -0,0 +1,300 @@ +"""Sandboxed expression evaluator for workflow templates. + +Provides a safe Jinja2 subset for evaluating expressions in workflow YAML. +No file I/O, no imports, no arbitrary code execution. +""" + +from __future__ import annotations + +import re +from typing import Any + + +# -- Custom filters ------------------------------------------------------- + +def _filter_default(value: Any, default_value: Any = "") -> Any: + """Return *default_value* when *value* is ``None`` or empty string.""" + if value is None or value == "": + return default_value + return value + + +def _filter_join(value: Any, separator: str = ", ") -> str: + """Join a list into a string with *separator*.""" + if isinstance(value, list): + return separator.join(str(v) for v in value) + return str(value) + + +def _filter_map(value: Any, attr: str) -> list[Any]: + """Map a list of dicts to a specific attribute.""" + if isinstance(value, list): + result = [] + for item in value: + if isinstance(item, dict): + # Support dot notation: "result.status" → item["result"]["status"] + parts = attr.split(".") + v = item + for part in parts: + if isinstance(v, dict): + v = v.get(part) + else: + v = None + break + result.append(v) + else: + result.append(item) + return result + return [] + + +def _filter_contains(value: Any, substring: str) -> bool: + """Check if a string or list contains *substring*.""" + if isinstance(value, str): + return substring in value + if isinstance(value, list): + return substring in value + return False + + +# -- Expression resolution ------------------------------------------------ + +_EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}") + + +def _resolve_dot_path(obj: Any, path: str) -> Any: + """Resolve a dotted path like ``steps.specify.output.file`` against *obj*. + + Supports dict key access and list indexing (e.g., ``task_list[0]``). + """ + parts = path.split(".") + current = obj + for part in parts: + # Handle list indexing: name[0] + idx_match = re.match(r"^([\w-]+)\[(\d+)\]$", part) + if idx_match: + key, idx = idx_match.group(1), int(idx_match.group(2)) + if isinstance(current, dict): + current = current.get(key) + else: + return None + if isinstance(current, list) and 0 <= idx < len(current): + current = current[idx] + else: + return None + elif isinstance(current, dict): + current = current.get(part) + else: + return None + if current is None: + return None + return current + + +def _build_namespace(context: Any) -> dict[str, Any]: + """Build the variable namespace from a StepContext.""" + ns: dict[str, Any] = {} + if hasattr(context, "inputs"): + ns["inputs"] = context.inputs or {} + if hasattr(context, "steps"): + ns["steps"] = context.steps or {} + if hasattr(context, "item"): + ns["item"] = context.item + if hasattr(context, "fan_in"): + ns["fan_in"] = context.fan_in or {} + return ns + + +def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any: + """Evaluate a simple expression against the namespace. + + Supports: + - Dot-path access: ``steps.specify.output.file`` + - Comparisons: ``==``, ``!=``, ``>``, ``<``, ``>=``, ``<=`` + - Boolean operators: ``and``, ``or``, ``not`` + - ``in``, ``not in`` + - Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| map('...')`` + - String and numeric literals + """ + expr = expr.strip() + + # String literal — check before pipes and operators so quoted strings + # containing | or operator keywords are not mis-parsed. + if (expr.startswith("'") and expr.endswith("'")) or ( + expr.startswith('"') and expr.endswith('"') + ): + return expr[1:-1] + + # Handle pipe filters + if "|" in expr: + parts = expr.split("|", 1) + value = _evaluate_simple_expression(parts[0].strip(), namespace) + filter_expr = parts[1].strip() + + # Parse filter name and argument + filter_match = re.match(r"(\w+)\((.+)\)", filter_expr) + if filter_match: + fname = filter_match.group(1) + farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace) + if fname == "default": + return _filter_default(value, farg) + if fname == "join": + return _filter_join(value, farg) + if fname == "map": + return _filter_map(value, farg) + if fname == "contains": + return _filter_contains(value, farg) + # Filter without args + filter_name = filter_expr.strip() + if filter_name == "default": + return _filter_default(value) + return value + + # Boolean operators — parse 'or' first (lower precedence) so that + # 'a or b and c' is evaluated as 'a or (b and c)'. + if " or " in expr: + parts = expr.split(" or ", 1) + left = _evaluate_simple_expression(parts[0].strip(), namespace) + right = _evaluate_simple_expression(parts[1].strip(), namespace) + return bool(left) or bool(right) + + if " and " in expr: + parts = expr.split(" and ", 1) + left = _evaluate_simple_expression(parts[0].strip(), namespace) + right = _evaluate_simple_expression(parts[1].strip(), namespace) + return bool(left) and bool(right) + + if expr.startswith("not "): + inner = _evaluate_simple_expression(expr[4:].strip(), namespace) + return not bool(inner) + + # Comparison operators (order matters — check multi-char ops first) + for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "): + if op in expr: + parts = expr.split(op, 1) + left = _evaluate_simple_expression(parts[0].strip(), namespace) + right = _evaluate_simple_expression(parts[1].strip(), namespace) + if op == "==": + return left == right + if op == "!=": + return left != right + if op == ">": + return _safe_compare(left, right, ">") + if op == "<": + return _safe_compare(left, right, "<") + if op == ">=": + return _safe_compare(left, right, ">=") + if op == "<=": + return _safe_compare(left, right, "<=") + if op == " in ": + return left in right if right is not None else False + if op == " not in ": + return left not in right if right is not None else True + + # Numeric literal + try: + if "." in expr: + return float(expr) + return int(expr) + except (ValueError, TypeError): + pass + + # Boolean literal + if expr.lower() == "true": + return True + if expr.lower() == "false": + return False + + # Null + if expr.lower() in ("none", "null"): + return None + + # List literal (simple) + if expr.startswith("[") and expr.endswith("]"): + inner = expr[1:-1].strip() + if not inner: + return [] + items = [_evaluate_simple_expression(i.strip(), namespace) for i in inner.split(",")] + return items + + # Variable reference (dot-path) + return _resolve_dot_path(namespace, expr) + + +def _safe_compare(left: Any, right: Any, op: str) -> bool: + """Safely compare two values, coercing types when possible.""" + try: + if isinstance(left, str): + left = float(left) if "." in left else int(left) + if isinstance(right, str): + right = float(right) if "." in right else int(right) + except (ValueError, TypeError): + return False + try: + if op == ">": + return left > right # type: ignore[operator] + if op == "<": + return left < right # type: ignore[operator] + if op == ">=": + return left >= right # type: ignore[operator] + if op == "<=": + return left <= right # type: ignore[operator] + except TypeError: + return False + return False + + +def evaluate_expression(template: str, context: Any) -> Any: + """Evaluate a template string with ``{{ ... }}`` expressions. + + If the entire string is a single expression, returns the raw value + (preserving type). Otherwise, substitutes each expression inline + and returns a string. + + Parameters + ---------- + template: + The template string (e.g., ``"{{ steps.plan.output.task_count }}"`` + or ``"Processed {{ inputs.feature_name }}"``. + context: + A ``StepContext`` or compatible object. + + Returns + ------- + The resolved value (any type for single-expression templates, + string for multi-expression or mixed templates). + """ + if not isinstance(template, str): + return template + + namespace = _build_namespace(context) + + # Single expression: return typed value + match = _EXPR_PATTERN.fullmatch(template.strip()) + if match: + return _evaluate_simple_expression(match.group(1).strip(), namespace) + + # Multi-expression: string interpolation + def _replacer(m: re.Match[str]) -> str: + val = _evaluate_simple_expression(m.group(1).strip(), namespace) + return str(val) if val is not None else "" + + return _EXPR_PATTERN.sub(_replacer, template) + + +def evaluate_condition(condition: str, context: Any) -> bool: + """Evaluate a condition expression and return a boolean. + + Convenience wrapper around ``evaluate_expression`` that coerces + the result to bool. + """ + result = evaluate_expression(condition, context) + # Treat plain "false"/"true" strings as booleans so that + # condition: "false" (without {{ }}) behaves as expected. + if isinstance(result, str): + lower = result.lower() + if lower == "false": + return False + if lower == "true": + return True + return bool(result) diff --git a/src/specify_cli/workflows/steps/__init__.py b/src/specify_cli/workflows/steps/__init__.py new file mode 100644 index 0000000000..0aa9182dd0 --- /dev/null +++ b/src/specify_cli/workflows/steps/__init__.py @@ -0,0 +1 @@ +"""Auto-discovery for built-in step types.""" diff --git a/src/specify_cli/workflows/steps/command/__init__.py b/src/specify_cli/workflows/steps/command/__init__.py new file mode 100644 index 0000000000..21fd4837d1 --- /dev/null +++ b/src/specify_cli/workflows/steps/command/__init__.py @@ -0,0 +1,155 @@ +"""Command step — dispatches a Spec Kit command to an integration CLI.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class CommandStep(StepBase): + """Default step type — invokes a Spec Kit command via the integration CLI. + + The command files (skills, markdown, TOML) are already installed in + the integration's directory on disk. This step tells the CLI to + execute the command by name (e.g. ``/speckit.specify`` or + ``/speckit-specify``) rather than reading the file contents. + + .. note:: + + CLI output is streamed to the terminal for live progress. + ``output.exit_code`` is always captured and can be referenced + by later steps (e.g. ``{{ steps.specify.output.exit_code }}``). + Full ``stdout``/``stderr`` capture is a planned enhancement. + """ + + type_key = "command" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + command = config.get("command", "") + input_data = config.get("input", {}) + + # Resolve expressions in input + resolved_input: dict[str, Any] = {} + for key, value in input_data.items(): + resolved_input[key] = evaluate_expression(value, context) + + # Resolve integration (step → workflow default → project default) + integration = config.get("integration") or context.default_integration + if integration and isinstance(integration, str) and "{{" in integration: + integration = evaluate_expression(integration, context) + + # Resolve model + model = config.get("model") or context.default_model + if model and isinstance(model, str) and "{{" in model: + model = evaluate_expression(model, context) + + # Merge options (workflow defaults ← step overrides) + options = dict(context.default_options) + step_options = config.get("options", {}) + if step_options: + options.update(step_options) + + # Attempt CLI dispatch + args_str = str(resolved_input.get("args", "")) + dispatch_result = self._try_dispatch( + command, integration, model, args_str, context + ) + + output: dict[str, Any] = { + "command": command, + "integration": integration, + "model": model, + "options": options, + "input": resolved_input, + } + + if dispatch_result is not None: + output["exit_code"] = dispatch_result["exit_code"] + output["stdout"] = dispatch_result["stdout"] + output["stderr"] = dispatch_result["stderr"] + output["dispatched"] = True + if dispatch_result["exit_code"] != 0: + return StepResult( + status=StepStatus.FAILED, + output=output, + error=dispatch_result["stderr"] or f"Command exited with code {dispatch_result['exit_code']}", + ) + return StepResult( + status=StepStatus.COMPLETED, + output=output, + ) + else: + output["exit_code"] = 1 + output["dispatched"] = False + return StepResult( + status=StepStatus.FAILED, + output=output, + error=( + f"Cannot dispatch command {command!r}: " + f"integration {integration!r} CLI not found or not installed. " + f"Install the CLI tool or check 'specify integration list'." + ), + ) + + @staticmethod + def _try_dispatch( + command: str, + integration_key: str | None, + model: str | None, + args: str, + context: StepContext, + ) -> dict[str, Any] | None: + """Invoke *command* by name through the integration CLI. + + The integration's ``dispatch_command`` builds the native + slash-command invocation (e.g. ``/speckit.specify`` for + markdown agents, ``/speckit-specify`` for skills agents), + then executes the CLI non-interactively. + + Returns the dispatch result dict, or ``None`` if dispatch is + not possible (integration not found, CLI not installed, or + dispatch not supported). + """ + if not integration_key: + return None + + try: + from specify_cli.integrations import get_integration + except ImportError: + return None + + impl = get_integration(integration_key) + if impl is None: + return None + + # Check if the integration supports CLI dispatch + if impl.build_exec_args("test") is None: + return None + + # Check if the CLI tool is actually installed + if not shutil.which(impl.key): + return None + + project_root = Path(context.project_root) if context.project_root else None + + try: + return impl.dispatch_command( + command, + args=args, + project_root=project_root, + model=model, + ) + except (NotImplementedError, OSError): + return None + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "command" not in config: + errors.append( + f"Command step {config.get('id', '?')!r} is missing 'command' field." + ) + return errors diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py new file mode 100644 index 0000000000..47a4d34437 --- /dev/null +++ b/src/specify_cli/workflows/steps/do_while/__init__.py @@ -0,0 +1,61 @@ +"""Do-While loop step — execute at least once, then repeat while condition is truthy.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus + + +class DoWhileStep(StepBase): + """Execute body at least once, then check condition. + + Continues while condition is truthy. ``max_iterations`` is an + optional safety cap (defaults to 10 if omitted). + + The first invocation always returns the nested steps for execution. + The engine re-evaluates ``step_config['condition']`` after each + iteration to decide whether to loop again. + """ + + type_key = "do-while" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + max_iterations = config.get("max_iterations") + if max_iterations is None: + max_iterations = 10 + nested_steps = config.get("steps", []) + condition = config.get("condition", "false") + + # Always execute body at least once; the engine layer evaluates + # `condition` after each iteration to decide whether to loop. + return StepResult( + status=StepStatus.COMPLETED, + output={ + "condition": condition, + "max_iterations": max_iterations, + "loop_type": "do-while", + }, + next_steps=nested_steps, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "condition" not in config: + errors.append( + f"Do-while step {config.get('id', '?')!r} is missing " + f"'condition' field." + ) + max_iter = config.get("max_iterations") + if max_iter is not None: + if 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." + ) + nested = config.get("steps", []) + if not isinstance(nested, list): + errors.append( + f"Do-while step {config.get('id', '?')!r}: 'steps' must be a list." + ) + return errors diff --git a/src/specify_cli/workflows/steps/fan_in/__init__.py b/src/specify_cli/workflows/steps/fan_in/__init__.py new file mode 100644 index 0000000000..dec3e3fd4d --- /dev/null +++ b/src/specify_cli/workflows/steps/fan_in/__init__.py @@ -0,0 +1,61 @@ +"""Fan-in step — join point for parallel steps.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class FanInStep(StepBase): + """Join point that aggregates results from ``wait_for:`` steps. + + Reads completed step outputs from ``context.steps`` and collects + them into ``output.results``. Does not block; relies on the + engine executing steps sequentially. + """ + + type_key = "fan-in" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + wait_for = config.get("wait_for", []) + output_config = config.get("output") or {} + if not isinstance(output_config, dict): + output_config = {} + + # Collect results from referenced steps + results = [] + for step_id in wait_for: + step_data = context.steps.get(step_id, {}) + results.append(step_data.get("output", {})) + + # Resolve output expressions with fan_in in context + prev_fan_in = getattr(context, "fan_in", None) + context.fan_in = {"results": results} + resolved_output: dict[str, Any] = {"results": results} + + try: + for key, expr in output_config.items(): + if isinstance(expr, str) and "{{" in expr: + resolved_output[key] = evaluate_expression(expr, context) + else: + resolved_output[key] = expr + finally: + # Restore previous fan_in state even if evaluation fails + context.fan_in = prev_fan_in + + return StepResult( + status=StepStatus.COMPLETED, + output=resolved_output, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + wait_for = config.get("wait_for", []) + if not isinstance(wait_for, list) or not wait_for: + errors.append( + f"Fan-in step {config.get('id', '?')!r}: " + f"'wait_for' must be a non-empty list of step IDs." + ) + return errors diff --git a/src/specify_cli/workflows/steps/fan_out/__init__.py b/src/specify_cli/workflows/steps/fan_out/__init__.py new file mode 100644 index 0000000000..c2fff1face --- /dev/null +++ b/src/specify_cli/workflows/steps/fan_out/__init__.py @@ -0,0 +1,58 @@ +"""Fan-out step — dispatch a step template over a collection.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class FanOutStep(StepBase): + """Dispatch a step template for each item in a collection. + + The engine executes the nested ``step:`` template once per item, + setting ``context.item`` for each iteration. Execution is + currently sequential; ``max_concurrency`` is accepted but not + enforced. + """ + + type_key = "fan-out" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + items_expr = config.get("items", "[]") + items = evaluate_expression(items_expr, context) + if not isinstance(items, list): + items = [] + + max_concurrency = config.get("max_concurrency", 1) + step_template = config.get("step", {}) + + return StepResult( + status=StepStatus.COMPLETED, + output={ + "items": items, + "max_concurrency": max_concurrency, + "step_template": step_template, + "item_count": len(items), + }, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "items" not in config: + errors.append( + f"Fan-out step {config.get('id', '?')!r} is missing " + f"'items' field." + ) + if "step" not in config: + errors.append( + f"Fan-out step {config.get('id', '?')!r} is missing " + f"'step' field (nested step template)." + ) + step = config.get("step") + if step is not None and not isinstance(step, dict): + errors.append( + f"Fan-out step {config.get('id', '?')!r}: 'step' must be a mapping." + ) + return errors diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/steps/gate/__init__.py new file mode 100644 index 0000000000..d4d32d763c --- /dev/null +++ b/src/specify_cli/workflows/steps/gate/__init__.py @@ -0,0 +1,121 @@ +"""Gate step — human review gate.""" + +from __future__ import annotations + +import sys +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class GateStep(StepBase): + """Interactive review gate. + + When running in an interactive terminal, prompts the user to choose + an option (e.g. approve / reject). Falls back to ``PAUSED`` when + stdin is not a TTY (CI, piped input) so the run can be resumed + later with ``specify workflow resume``. + + The user's choice is stored in ``output.choice``. ``on_reject`` + controls abort / skip behaviour. + """ + + type_key = "gate" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + message = config.get("message", "Review required.") + if isinstance(message, str) and "{{" in message: + message = evaluate_expression(message, context) + + options = config.get("options", ["approve", "reject"]) + on_reject = config.get("on_reject", "abort") + + show_file = config.get("show_file") + if show_file and isinstance(show_file, str) and "{{" in show_file: + show_file = evaluate_expression(show_file, context) + + output = { + "message": message, + "options": options, + "on_reject": on_reject, + "show_file": show_file, + "choice": None, + } + + # Non-interactive: pause for later resume + if not sys.stdin.isatty(): + return StepResult(status=StepStatus.PAUSED, output=output) + + # Interactive: prompt the user + choice = self._prompt(message, options) + output["choice"] = choice + + if choice in ("reject", "abort"): + if on_reject == "abort": + output["aborted"] = True + return StepResult( + status=StepStatus.FAILED, + output=output, + error=f"Gate rejected by user at step {config.get('id', '?')!r}", + ) + if on_reject == "retry": + # Pause so the next resume re-executes this gate + return StepResult(status=StepStatus.PAUSED, output=output) + # on_reject == "skip" → completed, downstream steps decide + return StepResult(status=StepStatus.COMPLETED, output=output) + + return StepResult(status=StepStatus.COMPLETED, output=output) + + @staticmethod + def _prompt(message: str, options: list[str]) -> str: + """Display gate message and prompt for a choice.""" + print("\n ┌─ Gate ─────────────────────────────────────") + print(f" │ {message}") + print(" │") + for i, opt in enumerate(options, 1): + print(f" │ [{i}] {opt}") + print(" └────────────────────────────────────────────") + + while True: + try: + raw = input(f" Choose [1-{len(options)}]: ").strip() + except (EOFError, KeyboardInterrupt): + print() + return options[-1] # default to last (usually reject) + if raw.isdigit() and 1 <= int(raw) <= len(options): + return options[int(raw) - 1] + # Also accept the option name directly + if raw.lower() in [o.lower() for o in options]: + return next(o for o in options if o.lower() == raw.lower()) + print(f" Invalid choice. Enter 1-{len(options)} or an option name.") + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "message" not in config: + errors.append( + f"Gate step {config.get('id', '?')!r} is missing 'message' field." + ) + options = config.get("options", ["approve", "reject"]) + if not isinstance(options, list) or not options: + errors.append( + f"Gate step {config.get('id', '?')!r}: 'options' must be a non-empty list." + ) + elif not all(isinstance(o, str) for o in options): + errors.append( + f"Gate step {config.get('id', '?')!r}: all options must be strings." + ) + on_reject = config.get("on_reject", "abort") + if on_reject not in ("abort", "skip", "retry"): + errors.append( + f"Gate step {config.get('id', '?')!r}: 'on_reject' must be " + f"'abort', 'skip', or 'retry'." + ) + if on_reject in ("abort", "retry") and isinstance(options, list): + reject_choices = {"reject", "abort"} + if not any(o.lower() in reject_choices for o in options): + errors.append( + f"Gate step {config.get('id', '?')!r}: on_reject={on_reject!r} " + f"but options has no 'reject' or 'abort' choice." + ) + return errors diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py new file mode 100644 index 0000000000..5b921a31a5 --- /dev/null +++ b/src/specify_cli/workflows/steps/if_then/__init__.py @@ -0,0 +1,55 @@ +"""If/Then/Else step — conditional branching.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_condition + + +class IfThenStep(StepBase): + """Branch based on a boolean condition expression. + + Both ``then:`` and ``else:`` contain inline step arrays — full step + definitions, not ID references. + """ + + type_key = "if" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + condition = config.get("condition", False) + result = evaluate_condition(condition, context) + + if result: + branch = config.get("then", []) + else: + branch = config.get("else", []) + + return StepResult( + status=StepStatus.COMPLETED, + output={"condition_result": result}, + next_steps=branch, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "condition" not in config: + errors.append( + f"If step {config.get('id', '?')!r} is missing 'condition' field." + ) + if "then" not in config: + errors.append( + f"If step {config.get('id', '?')!r} is missing 'then' field." + ) + then_branch = config.get("then", []) + if not isinstance(then_branch, list): + errors.append( + f"If step {config.get('id', '?')!r}: 'then' must be a list of steps." + ) + else_branch = config.get("else", []) + if else_branch and not isinstance(else_branch, list): + errors.append( + f"If step {config.get('id', '?')!r}: 'else' must be a list of steps." + ) + return errors diff --git a/src/specify_cli/workflows/steps/prompt/__init__.py b/src/specify_cli/workflows/steps/prompt/__init__.py new file mode 100644 index 0000000000..44fa22508b --- /dev/null +++ b/src/specify_cli/workflows/steps/prompt/__init__.py @@ -0,0 +1,156 @@ +"""Prompt step — sends an arbitrary prompt to an integration CLI.""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class PromptStep(StepBase): + """Send a free-form prompt to an integration CLI. + + Unlike ``CommandStep`` which invokes an installed Spec Kit command + by name (e.g. ``/speckit.specify`` or ``/speckit-specify``), + ``PromptStep`` sends an arbitrary inline ``prompt:`` string + directly to the CLI. This is useful for ad-hoc instructions + that don't map to a registered command. + + .. note:: + + CLI output is streamed to the terminal for live progress. + ``output.exit_code`` is always captured and can be referenced + by later steps. Full response text capture is a planned + enhancement. + + Example YAML:: + + - id: review-security + type: prompt + prompt: "Review {{ inputs.file }} for security vulnerabilities" + integration: claude + """ + + type_key = "prompt" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + prompt_template = config.get("prompt", "") + prompt = evaluate_expression(prompt_template, context) + if not isinstance(prompt, str): + prompt = str(prompt) + + # Resolve integration (step → workflow default) + integration = config.get("integration") or context.default_integration + if integration and isinstance(integration, str) and "{{" in integration: + integration = evaluate_expression(integration, context) + + # Resolve model + model = config.get("model") or context.default_model + if model and isinstance(model, str) and "{{" in model: + model = evaluate_expression(model, context) + + # Attempt CLI dispatch + dispatch_result = self._try_dispatch( + prompt, integration, model, context + ) + + output: dict[str, Any] = { + "prompt": prompt, + "integration": integration, + "model": model, + } + + if dispatch_result is not None: + output["exit_code"] = dispatch_result["exit_code"] + output["stdout"] = dispatch_result["stdout"] + output["stderr"] = dispatch_result["stderr"] + output["dispatched"] = True + if dispatch_result["exit_code"] != 0: + return StepResult( + status=StepStatus.FAILED, + output=output, + error=( + dispatch_result["stderr"] + or f"Prompt exited with code {dispatch_result['exit_code']}" + ), + ) + return StepResult( + status=StepStatus.COMPLETED, + output=output, + ) + else: + output["exit_code"] = 1 + output["dispatched"] = False + return StepResult( + status=StepStatus.FAILED, + output=output, + error=( + f"Cannot dispatch prompt: " + f"integration {integration!r} " + f"CLI not found or not installed." + ), + ) + + @staticmethod + def _try_dispatch( + prompt: str, + integration_key: str | None, + model: str | None, + context: StepContext, + ) -> dict[str, Any] | None: + """Dispatch *prompt* directly through the integration CLI.""" + if not integration_key or not prompt: + return None + + try: + from specify_cli.integrations import get_integration + except ImportError: + return None + + impl = get_integration(integration_key) + if impl is None: + return None + + exec_args = impl.build_exec_args(prompt, model=model, output_json=False) + if exec_args is None: + return None + + if not shutil.which(impl.key): + return None + + import subprocess + + project_root = ( + Path(context.project_root) if context.project_root else Path.cwd() + ) + + try: + result = subprocess.run( + exec_args, + text=True, + cwd=str(project_root), + ) + return { + "exit_code": result.returncode, + "stdout": "", + "stderr": "", + } + except KeyboardInterrupt: + return { + "exit_code": 130, + "stdout": "", + "stderr": "Interrupted by user", + } + except OSError: + return None + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "prompt" not in config: + errors.append( + f"Prompt step {config.get('id', '?')!r} is missing 'prompt' field." + ) + return errors diff --git a/src/specify_cli/workflows/steps/shell/__init__.py b/src/specify_cli/workflows/steps/shell/__init__.py new file mode 100644 index 0000000000..73ac99530a --- /dev/null +++ b/src/specify_cli/workflows/steps/shell/__init__.py @@ -0,0 +1,75 @@ +"""Shell step — run a local shell command.""" + +from __future__ import annotations + +import subprocess +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class ShellStep(StepBase): + """Run a local shell command (non-agent). + + Captures exit code and stdout/stderr. + """ + + type_key = "shell" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + run_cmd = config.get("run", "") + if isinstance(run_cmd, str) and "{{" in run_cmd: + run_cmd = evaluate_expression(run_cmd, context) + run_cmd = str(run_cmd) + + cwd = context.project_root or "." + + # NOTE: shell=True is required to support pipes, redirects, and + # multi-command expressions in workflow YAML. Workflow authors + # control commands; catalog-installed workflows should be reviewed + # before use (see PUBLISHING.md for security guidance). + try: + proc = subprocess.run( + run_cmd, + shell=True, + capture_output=True, + text=True, + cwd=cwd, + timeout=300, + ) + output = { + "exit_code": proc.returncode, + "stdout": proc.stdout, + "stderr": proc.stderr, + } + if proc.returncode != 0: + return StepResult( + status=StepStatus.FAILED, + error=f"Shell command exited with code {proc.returncode}.", + output=output, + ) + return StepResult( + status=StepStatus.COMPLETED, + output=output, + ) + except subprocess.TimeoutExpired: + return StepResult( + status=StepStatus.FAILED, + error="Shell command timed out after 300 seconds.", + output={"exit_code": -1, "stdout": "", "stderr": "timeout"}, + ) + except OSError as exc: + return StepResult( + status=StepStatus.FAILED, + error=f"Shell command failed: {exc}", + output={"exit_code": -1, "stdout": "", "stderr": str(exc)}, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "run" not in config: + errors.append( + f"Shell step {config.get('id', '?')!r} is missing 'run' field." + ) + return errors diff --git a/src/specify_cli/workflows/steps/switch/__init__.py b/src/specify_cli/workflows/steps/switch/__init__.py new file mode 100644 index 0000000000..e58d3c23c3 --- /dev/null +++ b/src/specify_cli/workflows/steps/switch/__init__.py @@ -0,0 +1,70 @@ +"""Switch step — multi-branch dispatch.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_expression + + +class SwitchStep(StepBase): + """Multi-branch dispatch on an expression. + + Evaluates ``expression:`` once, matches against ``cases:`` keys + (exact match, string-coerced). Falls through to ``default:`` if + no case matches. + """ + + type_key = "switch" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + expression = config.get("expression", "") + value = evaluate_expression(expression, context) + + # String-coerce for matching + str_value = str(value) if value is not None else "" + + cases = config.get("cases", {}) + for case_key, case_steps in cases.items(): + if str(case_key) == str_value: + return StepResult( + status=StepStatus.COMPLETED, + output={"matched_case": str(case_key), "expression_value": value}, + next_steps=case_steps, + ) + + # Default fallback + default_steps = config.get("default", []) + return StepResult( + status=StepStatus.COMPLETED, + output={"matched_case": "__default__", "expression_value": value}, + next_steps=default_steps, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "expression" not in config: + errors.append( + f"Switch step {config.get('id', '?')!r} is missing " + f"'expression' field." + ) + cases = config.get("cases", {}) + if not isinstance(cases, dict): + errors.append( + f"Switch step {config.get('id', '?')!r}: 'cases' must be a mapping." + ) + else: + for key, val in cases.items(): + if not isinstance(val, list): + errors.append( + f"Switch step {config.get('id', '?')!r}: " + f"case {key!r} must be a list of steps." + ) + default = config.get("default") + if default is not None and not isinstance(default, list): + errors.append( + f"Switch step {config.get('id', '?')!r}: " + f"'default' must be a list of steps." + ) + return errors diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py new file mode 100644 index 0000000000..18c2f46050 --- /dev/null +++ b/src/specify_cli/workflows/steps/while_loop/__init__.py @@ -0,0 +1,68 @@ +"""While loop step — repeat while condition is truthy.""" + +from __future__ import annotations + +from typing import Any + +from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus +from specify_cli.workflows.expressions import evaluate_condition + + +class WhileStep(StepBase): + """Repeat nested steps while condition is truthy. + + Evaluates condition *before* each iteration. If falsy on first + check, the body never runs. ``max_iterations`` is an optional + safety cap (defaults to 10 if omitted). + """ + + type_key = "while" + + def execute(self, config: dict[str, Any], context: StepContext) -> StepResult: + condition = config.get("condition", False) + max_iterations = config.get("max_iterations") + if max_iterations is None: + max_iterations = 10 + nested_steps = config.get("steps", []) + + result = evaluate_condition(condition, context) + if result: + return StepResult( + status=StepStatus.COMPLETED, + output={ + "condition_result": True, + "max_iterations": max_iterations, + "loop_type": "while", + }, + next_steps=nested_steps, + ) + + return StepResult( + status=StepStatus.COMPLETED, + output={ + "condition_result": False, + "max_iterations": max_iterations, + "loop_type": "while", + }, + ) + + def validate(self, config: dict[str, Any]) -> list[str]: + errors = super().validate(config) + if "condition" not in config: + errors.append( + f"While step {config.get('id', '?')!r} is missing " + f"'condition' field." + ) + max_iter = config.get("max_iterations") + if max_iter is not None: + if 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." + ) + nested = config.get("steps", []) + if not isinstance(nested, list): + errors.append( + f"While step {config.get('id', '?')!r}: 'steps' must be a list." + ) + return errors diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index e274b52242..3700d35de5 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -245,6 +245,9 @@ def _expected_files(self, script_variant: str) -> list[str]: 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") return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 007386611c..72d32278ba 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -347,6 +347,11 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/templates/spec-template.md", ".specify/templates/tasks-template.md", ] + # Bundled workflow + files += [ + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", + ] return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 4d0bfe2cfe..e80f9abc10 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -505,6 +505,9 @@ def _expected_files(self, script_variant: str) -> list[str]: 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") return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index b0f59a627d..e4c31b3c88 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -384,6 +384,9 @@ def _expected_files(self, script_variant: str) -> list[str]: 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") return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 5db0155bdb..34a9d54945 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -199,6 +199,8 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/templates/spec-template.md", ".specify/templates/tasks-template.md", ".specify/memory/constitution.md", + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", ]) assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" @@ -259,6 +261,8 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/templates/spec-template.md", ".specify/templates/tasks-template.md", ".specify/memory/constitution.md", + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", ]) assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 2815456f21..74034ef105 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -248,6 +248,8 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/templates/plan-template.md", ".specify/templates/spec-template.md", ".specify/templates/tasks-template.md", + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", ]) assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" @@ -304,6 +306,8 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/templates/plan-template.md", ".specify/templates/spec-template.md", ".specify/templates/tasks-template.md", + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", ]) assert actual == expected, ( f"Missing: {sorted(set(expected) - set(actual))}\n" diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000000..96893249e2 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,1803 @@ +"""Tests for the workflow engine subsystem. + +Covers: +- Step registry & auto-discovery +- Base classes (StepBase, StepContext, StepResult) +- Expression engine +- All 10 built-in step types +- Workflow definition loading & validation +- Workflow engine execution & state persistence +- Workflow catalog & registry +""" + +from __future__ import annotations + +import json +import shutil +import tempfile +from pathlib import Path + +import pytest +import yaml + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for tests.""" + tmpdir = tempfile.mkdtemp() + yield Path(tmpdir) + shutil.rmtree(tmpdir) + + +@pytest.fixture +def project_dir(temp_dir): + """Create a mock spec-kit project with .specify/ directory.""" + specify_dir = temp_dir / ".specify" + specify_dir.mkdir() + (specify_dir / "workflows").mkdir() + return temp_dir + + +@pytest.fixture +def sample_workflow_yaml(): + """Return a valid minimal workflow YAML string.""" + return """ +schema_version: "1.0" +workflow: + id: "test-workflow" + name: "Test Workflow" + version: "1.0.0" + description: "A test workflow" + +inputs: + feature_name: + type: string + required: true + scope: + type: string + default: "full" + +steps: + - id: step-one + command: speckit.specify + input: + args: "{{ inputs.feature_name }}" + + - id: step-two + command: speckit.plan + input: + args: "{{ steps.step-one.output.command }}" +""" + + +@pytest.fixture +def sample_workflow_file(project_dir, sample_workflow_yaml): + """Write a sample workflow YAML to a file and return its path.""" + wf_dir = project_dir / ".specify" / "workflows" / "test-workflow" + wf_dir.mkdir(parents=True, exist_ok=True) + wf_path = wf_dir / "workflow.yml" + wf_path.write_text(sample_workflow_yaml, encoding="utf-8") + return wf_path + + +# ===== Step Registry Tests ===== + +class TestStepRegistry: + """Test STEP_REGISTRY and auto-discovery.""" + + def test_registry_populated(self): + from specify_cli.workflows import STEP_REGISTRY + + assert len(STEP_REGISTRY) >= 10 + + def test_all_step_types_registered(self): + from specify_cli.workflows import STEP_REGISTRY + + expected = { + "command", "shell", "prompt", "gate", "if", "switch", + "while", "do-while", "fan-out", "fan-in", + } + assert expected.issubset(set(STEP_REGISTRY.keys())) + + def test_get_step_type(self): + from specify_cli.workflows import get_step_type + + step = get_step_type("command") + assert step is not None + assert step.type_key == "command" + + def test_get_step_type_missing(self): + from specify_cli.workflows import get_step_type + + assert get_step_type("nonexistent") is None + + def test_register_step_duplicate_raises(self): + from specify_cli.workflows import _register_step + from specify_cli.workflows.steps.command import CommandStep + + with pytest.raises(KeyError, match="already registered"): + _register_step(CommandStep()) + + def test_register_step_empty_key_raises(self): + from specify_cli.workflows import _register_step + from specify_cli.workflows.base import StepBase, StepResult + + class EmptyStep(StepBase): + type_key = "" + def execute(self, config, context): + return StepResult() + + with pytest.raises(ValueError, match="empty type_key"): + _register_step(EmptyStep()) + + +# ===== Base Classes Tests ===== + +class TestBaseClasses: + """Test StepBase, StepContext, StepResult.""" + + def test_step_context_defaults(self): + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert ctx.inputs == {} + assert ctx.steps == {} + assert ctx.item is None + assert ctx.fan_in == {} + assert ctx.default_integration is None + + def test_step_context_with_data(self): + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + inputs={"name": "test"}, + default_integration="claude", + default_model="sonnet-4", + ) + assert ctx.inputs == {"name": "test"} + assert ctx.default_integration == "claude" + assert ctx.default_model == "sonnet-4" + + def test_step_result_defaults(self): + from specify_cli.workflows.base import StepResult, StepStatus + + result = StepResult() + assert result.status == StepStatus.COMPLETED + assert result.output == {} + assert result.next_steps == [] + assert result.error is None + + def test_step_status_values(self): + from specify_cli.workflows.base import StepStatus + + assert StepStatus.PENDING == "pending" + assert StepStatus.RUNNING == "running" + assert StepStatus.COMPLETED == "completed" + assert StepStatus.FAILED == "failed" + assert StepStatus.SKIPPED == "skipped" + assert StepStatus.PAUSED == "paused" + + def test_run_status_values(self): + from specify_cli.workflows.base import RunStatus + + assert RunStatus.CREATED == "created" + assert RunStatus.RUNNING == "running" + assert RunStatus.PAUSED == "paused" + assert RunStatus.COMPLETED == "completed" + assert RunStatus.FAILED == "failed" + assert RunStatus.ABORTED == "aborted" + + +# ===== Expression Engine Tests ===== + +class TestExpressions: + """Test sandboxed expression evaluator.""" + + def test_simple_variable(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"name": "login"}) + assert evaluate_expression("{{ inputs.name }}", ctx) == "login" + + def test_step_output_reference(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"specify": {"output": {"file": "spec.md"}}} + ) + assert evaluate_expression("{{ steps.specify.output.file }}", ctx) == "spec.md" + + def test_string_interpolation(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"name": "login"}) + result = evaluate_expression("Feature: {{ inputs.name }} done", ctx) + assert result == "Feature: login done" + + def test_comparison_equals(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"scope": "full"}) + assert evaluate_expression("{{ inputs.scope == 'full' }}", ctx) is True + assert evaluate_expression("{{ inputs.scope == 'partial' }}", ctx) is False + + def test_comparison_not_equals(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 1}}} + ) + result = evaluate_expression("{{ steps.run-tests.output.exit_code != 0 }}", ctx) + assert result is True + + def test_numeric_comparison(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"plan": {"output": {"task_count": 7}}} + ) + assert evaluate_expression("{{ steps.plan.output.task_count > 5 }}", ctx) is True + assert evaluate_expression("{{ steps.plan.output.task_count < 5 }}", ctx) is False + + def test_boolean_and(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"a": True, "b": True}) + assert evaluate_expression("{{ inputs.a and inputs.b }}", ctx) is True + + def test_boolean_or(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"a": False, "b": True}) + assert evaluate_expression("{{ inputs.a or inputs.b }}", ctx) is True + + def test_filter_default(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ inputs.missing | default('fallback') }}", ctx) == "fallback" + + def test_filter_join(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"tags": ["a", "b", "c"]}) + assert evaluate_expression("{{ inputs.tags | join(', ') }}", ctx) == "a, b, c" + + def test_filter_contains(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"text": "hello world"}) + assert evaluate_expression("{{ inputs.text | contains('world') }}", ctx) is True + + def test_condition_evaluation(self): + from specify_cli.workflows.expressions import evaluate_condition + from specify_cli.workflows.base import StepContext + + ctx = StepContext(inputs={"ready": True}) + assert evaluate_condition("{{ inputs.ready }}", ctx) is True + assert evaluate_condition("{{ inputs.missing }}", ctx) is False + + def test_non_string_passthrough(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression(42, ctx) == 42 + assert evaluate_expression(None, ctx) is None + + def test_string_literal(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ 'hello' }}", ctx) == "hello" + + def test_numeric_literal(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ 42 }}", ctx) == 42 + + def test_boolean_literal(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext() + assert evaluate_expression("{{ true }}", ctx) is True + assert evaluate_expression("{{ false }}", ctx) is False + + def test_list_indexing(self): + from specify_cli.workflows.expressions import evaluate_expression + from specify_cli.workflows.base import StepContext + + ctx = StepContext( + steps={"tasks": {"output": {"task_list": [{"file": "a.md"}, {"file": "b.md"}]}}} + ) + result = evaluate_expression("{{ steps.tasks.output.task_list[0].file }}", ctx) + assert result == "a.md" + + +# ===== Integration Dispatch Tests ===== + +class TestBuildExecArgs: + """Test build_exec_args for CLI-based integrations.""" + + def test_claude_exec_args(self): + from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() + args = impl.build_exec_args("do stuff", model="sonnet-4") + assert args[0] == "claude" + assert args[1] == "-p" + assert args[2] == "do stuff" + assert "--model" in args + assert "sonnet-4" in args + assert "--output-format" in args + + def test_gemini_exec_args(self): + from specify_cli.integrations.gemini import GeminiIntegration + impl = GeminiIntegration() + args = impl.build_exec_args("do stuff", model="gemini-2.5-pro") + assert args[0] == "gemini" + assert args[1] == "-p" + assert "-m" in args + assert "gemini-2.5-pro" in args + + def test_codex_exec_args(self): + from specify_cli.integrations.codex import CodexIntegration + impl = CodexIntegration() + args = impl.build_exec_args("do stuff") + assert args[0] == "codex" + assert args[1] == "exec" + assert args[2] == "do stuff" + assert "--json" in args + + def test_copilot_exec_args(self): + from specify_cli.integrations.copilot import CopilotIntegration + impl = CopilotIntegration() + args = impl.build_exec_args("do stuff", model="claude-sonnet-4-20250514") + assert args[0] == "copilot" + assert "-p" in args + assert "--allow-all-tools" in args + assert "--model" in args + + def test_ide_only_returns_none(self): + from specify_cli.integrations.windsurf import WindsurfIntegration + impl = WindsurfIntegration() + assert impl.build_exec_args("test") is None + + def test_no_model_omits_flag(self): + from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() + args = impl.build_exec_args("do stuff", model=None) + assert "--model" not in args + + def test_no_json_omits_flag(self): + from specify_cli.integrations.claude import ClaudeIntegration + impl = ClaudeIntegration() + args = impl.build_exec_args("do stuff", output_json=False) + assert "--output-format" not in args + + +# ===== Step Type Tests ===== + +class TestCommandStep: + """Test the command step type.""" + + def test_execute_basic(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={"name": "login"}, + default_integration="claude", + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "{{ inputs.name }}"}, + } + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["command"] == "speckit.specify" + assert result.output["integration"] == "claude" + assert result.output["input"]["args"] == "login" + + def test_validate_missing_command(self): + from specify_cli.workflows.steps.command import CommandStep + + step = CommandStep() + errors = step.validate({"id": "test"}) + assert any("missing 'command'" in e for e in errors) + + def test_step_override_integration(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext + + step = CommandStep() + ctx = StepContext(default_integration="claude") + config = { + "id": "test", + "command": "speckit.plan", + "integration": "gemini", + "input": {}, + } + result = step.execute(config, ctx) + assert result.output["integration"] == "gemini" + + def test_step_override_model(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext + + step = CommandStep() + ctx = StepContext(default_model="sonnet-4") + config = { + "id": "test", + "command": "speckit.implement", + "model": "opus-4", + "input": {}, + } + result = step.execute(config, ctx) + assert result.output["model"] == "opus-4" + + def test_options_merge(self): + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext + + step = CommandStep() + ctx = StepContext(default_options={"max-tokens": 8000}) + config = { + "id": "test", + "command": "speckit.plan", + "options": {"thinking-budget": 32768}, + "input": {}, + } + result = step.execute(config, ctx) + assert result.output["options"]["max-tokens"] == 8000 + assert result.output["options"]["thinking-budget"] == 32768 + + def test_dispatch_not_attempted_without_cli(self): + """When the CLI tool is not installed, step should fail.""" + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={"name": "login"}, + default_integration="claude", + project_root="/tmp", + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "{{ inputs.name }}"}, + } + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["dispatched"] is False + assert result.error is not None + + def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch): + """When the CLI is installed, dispatch invokes the command by name.""" + from unittest.mock import patch, MagicMock + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={"name": "login"}, + default_integration="claude", + project_root=str(tmp_path), + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "{{ inputs.name }}"}, + } + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = '{"result": "done"}' + mock_result.stderr = "" + + with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result) as mock_run: + result = step.execute(config, ctx) + + assert result.status == StepStatus.COMPLETED + assert result.output["dispatched"] is True + assert result.output["exit_code"] == 0 + # Verify the CLI was called with -p and the skill invocation + call_args = mock_run.call_args + assert call_args[0][0][0] == "claude" + assert call_args[0][0][1] == "-p" + # Claude is a SkillsIntegration so uses /speckit-specify + assert "/speckit-specify login" in call_args[0][0][2] + + def test_dispatch_failure_returns_failed_status(self, tmp_path): + """When the CLI exits non-zero, the step should fail.""" + from unittest.mock import patch, MagicMock + from specify_cli.workflows.steps.command import CommandStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = CommandStep() + ctx = StepContext( + inputs={}, + default_integration="claude", + project_root=str(tmp_path), + ) + config = { + "id": "test", + "command": "speckit.specify", + "input": {"args": "test"}, + } + + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "API error" + + with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result): + result = step.execute(config, ctx) + + assert result.status == StepStatus.FAILED + assert result.output["dispatched"] is True + assert result.output["exit_code"] == 1 + + +class TestPromptStep: + """Test the prompt step type.""" + + def test_execute_basic(self): + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = PromptStep() + ctx = StepContext( + inputs={"file": "auth.py"}, + default_integration="claude", + ) + config = { + "id": "review", + "type": "prompt", + "prompt": "Review {{ inputs.file }} for security issues", + } + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["prompt"] == "Review auth.py for security issues" + assert result.output["integration"] == "claude" + assert result.output["dispatched"] is False + + def test_execute_with_step_integration(self): + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext + + step = PromptStep() + ctx = StepContext(default_integration="claude") + config = { + "id": "review", + "type": "prompt", + "prompt": "Summarize the codebase", + "integration": "gemini", + } + result = step.execute(config, ctx) + assert result.output["integration"] == "gemini" + + def test_execute_with_model(self): + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext + + step = PromptStep() + ctx = StepContext(default_integration="claude", default_model="sonnet-4") + config = { + "id": "review", + "type": "prompt", + "prompt": "hello", + "model": "opus-4", + } + result = step.execute(config, ctx) + assert result.output["model"] == "opus-4" + + def test_dispatch_with_mock_cli(self, tmp_path): + from unittest.mock import patch, MagicMock + from specify_cli.workflows.steps.prompt import PromptStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = PromptStep() + ctx = StepContext( + default_integration="claude", + project_root=str(tmp_path), + ) + config = { + "id": "ask", + "type": "prompt", + "prompt": "Explain this code", + } + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "Here is the explanation" + mock_result.stderr = "" + + with patch("specify_cli.workflows.steps.prompt.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("subprocess.run", return_value=mock_result): + result = step.execute(config, ctx) + + assert result.status == StepStatus.COMPLETED + assert result.output["dispatched"] is True + assert result.output["exit_code"] == 0 + + def test_validate_missing_prompt(self): + from specify_cli.workflows.steps.prompt import PromptStep + + step = PromptStep() + errors = step.validate({"id": "test"}) + assert any("missing 'prompt'" in e for e in errors) + + def test_validate_valid(self): + from specify_cli.workflows.steps.prompt import PromptStep + + step = PromptStep() + errors = step.validate({"id": "test", "prompt": "do something"}) + assert errors == [] + + +class TestShellStep: + """Test the shell step type.""" + + def test_execute_echo(self): + from specify_cli.workflows.steps.shell import ShellStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = ShellStep() + ctx = StepContext() + config = {"id": "test", "run": "echo hello"} + result = step.execute(config, ctx) + assert result.status == StepStatus.COMPLETED + assert result.output["exit_code"] == 0 + assert "hello" in result.output["stdout"] + + def test_execute_failure(self): + from specify_cli.workflows.steps.shell import ShellStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = ShellStep() + ctx = StepContext() + config = {"id": "test", "run": "exit 1"} + result = step.execute(config, ctx) + assert result.status == StepStatus.FAILED + assert result.output["exit_code"] == 1 + assert result.error is not None + + def test_validate_missing_run(self): + from specify_cli.workflows.steps.shell import ShellStep + + step = ShellStep() + errors = step.validate({"id": "test"}) + assert any("missing 'run'" in e for e in errors) + + +class TestGateStep: + """Test the gate step type.""" + + def test_execute_returns_paused(self): + from specify_cli.workflows.steps.gate import GateStep + from specify_cli.workflows.base import StepContext, StepStatus + + step = GateStep() + ctx = StepContext() + config = { + "id": "review", + "message": "Review the spec.", + "options": ["approve", "reject"], + "on_reject": "abort", + } + result = step.execute(config, ctx) + assert result.status == StepStatus.PAUSED + assert result.output["message"] == "Review the spec." + assert result.output["options"] == ["approve", "reject"] + + def test_validate_missing_message(self): + from specify_cli.workflows.steps.gate import GateStep + + step = GateStep() + errors = step.validate({"id": "test", "options": ["approve"]}) + assert any("missing 'message'" in e for e in errors) + + def test_validate_invalid_on_reject(self): + from specify_cli.workflows.steps.gate import GateStep + + step = GateStep() + errors = step.validate({ + "id": "test", + "message": "Review", + "on_reject": "invalid", + }) + assert any("on_reject" in e for e in errors) + + +class TestIfThenStep: + """Test the if/then/else step type.""" + + def test_execute_then_branch(self): + from specify_cli.workflows.steps.if_then import IfThenStep + from specify_cli.workflows.base import StepContext + + step = IfThenStep() + ctx = StepContext(inputs={"scope": "full"}) + config = { + "id": "check", + "condition": "{{ inputs.scope == 'full' }}", + "then": [{"id": "a", "command": "speckit.tasks"}], + "else": [{"id": "b", "command": "speckit.plan"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is True + assert len(result.next_steps) == 1 + assert result.next_steps[0]["id"] == "a" + + def test_execute_else_branch(self): + from specify_cli.workflows.steps.if_then import IfThenStep + from specify_cli.workflows.base import StepContext + + step = IfThenStep() + ctx = StepContext(inputs={"scope": "backend"}) + config = { + "id": "check", + "condition": "{{ inputs.scope == 'full' }}", + "then": [{"id": "a", "command": "speckit.tasks"}], + "else": [{"id": "b", "command": "speckit.plan"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is False + assert result.next_steps[0]["id"] == "b" + + def test_validate_missing_condition(self): + from specify_cli.workflows.steps.if_then import IfThenStep + + step = IfThenStep() + errors = step.validate({"id": "test", "then": []}) + assert any("missing 'condition'" in e for e in errors) + + +class TestSwitchStep: + """Test the switch step type.""" + + def test_execute_matches_case(self): + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + step = SwitchStep() + ctx = StepContext( + steps={"review": {"output": {"choice": "approve"}}} + ) + config = { + "id": "route", + "expression": "{{ steps.review.output.choice }}", + "cases": { + "approve": [{"id": "plan", "command": "speckit.plan"}], + "reject": [{"id": "log", "type": "shell", "run": "echo rejected"}], + }, + "default": [{"id": "abort", "type": "gate", "message": "Unknown"}], + } + result = step.execute(config, ctx) + assert result.output["matched_case"] == "approve" + assert result.next_steps[0]["id"] == "plan" + + def test_execute_falls_to_default(self): + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + step = SwitchStep() + ctx = StepContext( + steps={"review": {"output": {"choice": "unknown"}}} + ) + config = { + "id": "route", + "expression": "{{ steps.review.output.choice }}", + "cases": { + "approve": [{"id": "plan", "command": "speckit.plan"}], + }, + "default": [{"id": "fallback", "type": "gate", "message": "Fallback"}], + } + result = step.execute(config, ctx) + assert result.output["matched_case"] == "__default__" + assert result.next_steps[0]["id"] == "fallback" + + def test_execute_no_default_no_match(self): + from specify_cli.workflows.steps.switch import SwitchStep + from specify_cli.workflows.base import StepContext + + step = SwitchStep() + ctx = StepContext( + steps={"review": {"output": {"choice": "other"}}} + ) + config = { + "id": "route", + "expression": "{{ steps.review.output.choice }}", + "cases": { + "approve": [{"id": "plan", "command": "speckit.plan"}], + }, + } + result = step.execute(config, ctx) + assert result.output["matched_case"] == "__default__" + assert result.next_steps == [] + + def test_validate_missing_expression(self): + from specify_cli.workflows.steps.switch import SwitchStep + + step = SwitchStep() + errors = step.validate({"id": "test", "cases": {}}) + assert any("missing 'expression'" in e for e in errors) + + def test_validate_invalid_cases_and_default(self): + from specify_cli.workflows.steps.switch import SwitchStep + + step = SwitchStep() + errors = step.validate({ + "id": "test", + "expression": "{{ x }}", + "cases": {"a": "not-a-list"}, + "default": "also-bad", + }) + assert any("case 'a' must be a list" in e for e in errors) + assert any("'default' must be a list" in e for e in errors) + + +class TestWhileStep: + """Test the while loop step type.""" + + def test_execute_condition_true(self): + from specify_cli.workflows.steps.while_loop import WhileStep + from specify_cli.workflows.base import StepContext + + step = WhileStep() + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 1}}} + ) + config = { + "id": "retry", + "condition": "{{ steps.run-tests.output.exit_code != 0 }}", + "max_iterations": 5, + "steps": [{"id": "fix", "command": "speckit.implement"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is True + assert len(result.next_steps) == 1 + + def test_execute_condition_false(self): + from specify_cli.workflows.steps.while_loop import WhileStep + from specify_cli.workflows.base import StepContext + + step = WhileStep() + ctx = StepContext( + steps={"run-tests": {"output": {"exit_code": 0}}} + ) + config = { + "id": "retry", + "condition": "{{ steps.run-tests.output.exit_code != 0 }}", + "max_iterations": 5, + "steps": [{"id": "fix", "command": "speckit.implement"}], + } + result = step.execute(config, ctx) + assert result.output["condition_result"] is False + assert result.next_steps == [] + + def test_validate_missing_fields(self): + from specify_cli.workflows.steps.while_loop import WhileStep + + step = WhileStep() + errors = step.validate({"id": "test", "steps": []}) + assert any("missing 'condition'" in e for e in errors) + # max_iterations is optional (defaults to 10) + + def test_validate_invalid_max_iterations(self): + from specify_cli.workflows.steps.while_loop import WhileStep + + 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) + + +class TestDoWhileStep: + """Test the do-while loop step type.""" + + def test_execute_always_runs_once(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + from specify_cli.workflows.base import StepContext + + step = DoWhileStep() + ctx = StepContext() + config = { + "id": "cycle", + "condition": "{{ false }}", + "max_iterations": 3, + "steps": [{"id": "refine", "command": "speckit.specify"}], + } + result = step.execute(config, ctx) + assert len(result.next_steps) == 1 + assert result.output["loop_type"] == "do-while" + assert result.output["condition"] == "{{ false }}" + + def test_execute_with_true_condition(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + from specify_cli.workflows.base import StepContext + + step = DoWhileStep() + ctx = StepContext() + config = { + "id": "cycle", + "condition": "{{ true }}", + "max_iterations": 5, + "steps": [{"id": "work", "command": "speckit.plan"}], + } + result = step.execute(config, ctx) + # Body always executes on first call regardless of condition + assert len(result.next_steps) == 1 + assert result.output["max_iterations"] == 5 + + def test_execute_empty_steps(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + from specify_cli.workflows.base import StepContext + + step = DoWhileStep() + ctx = StepContext() + config = { + "id": "empty", + "condition": "{{ false }}", + "max_iterations": 1, + "steps": [], + } + result = step.execute(config, ctx) + assert result.next_steps == [] + assert result.status.value == "completed" + + def test_validate_missing_fields(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + errors = step.validate({"id": "test", "steps": []}) + assert any("missing 'condition'" in e for e in errors) + # max_iterations is optional (defaults to 10) + + def test_validate_steps_not_list(self): + from specify_cli.workflows.steps.do_while import DoWhileStep + + step = DoWhileStep() + errors = step.validate({ + "id": "test", + "condition": "{{ true }}", + "max_iterations": 3, + "steps": "not-a-list", + }) + assert any("'steps' must be a list" in e for e in errors) + + +class TestFanOutStep: + """Test the fan-out step type.""" + + def test_execute_with_items(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + from specify_cli.workflows.base import StepContext + + step = FanOutStep() + ctx = StepContext( + steps={"tasks": {"output": {"task_list": [ + {"file": "a.md"}, + {"file": "b.md"}, + ]}}} + ) + config = { + "id": "parallel", + "items": "{{ steps.tasks.output.task_list }}", + "max_concurrency": 3, + "step": {"id": "impl", "command": "speckit.implement"}, + } + result = step.execute(config, ctx) + assert result.output["item_count"] == 2 + assert result.output["max_concurrency"] == 3 + + def test_execute_non_list_items_resolves_empty(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + from specify_cli.workflows.base import StepContext + + step = FanOutStep() + ctx = StepContext() + config = { + "id": "parallel", + "items": "{{ undefined_var }}", + "step": {"id": "impl", "command": "speckit.implement"}, + } + result = step.execute(config, ctx) + assert result.output["item_count"] == 0 + assert result.output["items"] == [] + + def test_validate_missing_fields(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + + step = FanOutStep() + errors = step.validate({"id": "test"}) + assert any("missing 'items'" in e for e in errors) + assert any("missing 'step'" in e for e in errors) + + def test_validate_step_not_mapping(self): + from specify_cli.workflows.steps.fan_out import FanOutStep + + step = FanOutStep() + errors = step.validate({ + "id": "test", + "items": "{{ x }}", + "step": "not-a-dict", + }) + assert any("'step' must be a mapping" in e for e in errors) + + +class TestFanInStep: + """Test the fan-in step type.""" + + def test_execute_collects_results(self): + from specify_cli.workflows.steps.fan_in import FanInStep + from specify_cli.workflows.base import StepContext + + step = FanInStep() + ctx = StepContext( + steps={ + "parallel": {"output": {"item_count": 2, "status": "done"}} + } + ) + config = { + "id": "collect", + "wait_for": ["parallel"], + "output": {}, + } + result = step.execute(config, ctx) + assert len(result.output["results"]) == 1 + assert result.output["results"][0]["item_count"] == 2 + + def test_execute_multiple_wait_for(self): + from specify_cli.workflows.steps.fan_in import FanInStep + from specify_cli.workflows.base import StepContext + + step = FanInStep() + ctx = StepContext( + steps={ + "task-a": {"output": {"file": "a.md"}}, + "task-b": {"output": {"file": "b.md"}}, + } + ) + config = { + "id": "collect", + "wait_for": ["task-a", "task-b"], + "output": {}, + } + result = step.execute(config, ctx) + assert len(result.output["results"]) == 2 + assert result.output["results"][0]["file"] == "a.md" + assert result.output["results"][1]["file"] == "b.md" + + def test_execute_missing_wait_for_step(self): + from specify_cli.workflows.steps.fan_in import FanInStep + from specify_cli.workflows.base import StepContext + + step = FanInStep() + ctx = StepContext(steps={}) + config = { + "id": "collect", + "wait_for": ["nonexistent"], + "output": {}, + } + result = step.execute(config, ctx) + assert result.output["results"] == [{}] + + def test_validate_empty_wait_for(self): + from specify_cli.workflows.steps.fan_in import FanInStep + + step = FanInStep() + errors = step.validate({"id": "test", "wait_for": []}) + assert any("non-empty list" in e for e in errors) + + def test_validate_wait_for_not_list(self): + from specify_cli.workflows.steps.fan_in import FanInStep + + step = FanInStep() + errors = step.validate({"id": "test", "wait_for": "not-a-list"}) + assert any("non-empty list" in e for e in errors) + + +# ===== Workflow Definition Tests ===== + +class TestWorkflowDefinition: + """Test WorkflowDefinition loading and parsing.""" + + def test_from_yaml(self, sample_workflow_file): + from specify_cli.workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_yaml(sample_workflow_file) + assert definition.id == "test-workflow" + assert definition.name == "Test Workflow" + assert definition.version == "1.0.0" + assert len(definition.steps) == 2 + + def test_from_string(self, sample_workflow_yaml): + from specify_cli.workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_string(sample_workflow_yaml) + assert definition.id == "test-workflow" + assert len(definition.inputs) == 2 + + def test_from_string_invalid(self): + from specify_cli.workflows.engine import WorkflowDefinition + + with pytest.raises(ValueError, match="must be a mapping"): + WorkflowDefinition.from_string("- just a list") + + def test_inputs_parsed(self, sample_workflow_yaml): + from specify_cli.workflows.engine import WorkflowDefinition + + definition = WorkflowDefinition.from_string(sample_workflow_yaml) + assert "feature_name" in definition.inputs + assert definition.inputs["feature_name"]["required"] is True + assert definition.inputs["scope"]["default"] == "full" + + +# ===== Workflow Validation Tests ===== + +class TestWorkflowValidation: + """Test workflow validation.""" + + def test_valid_workflow(self, sample_workflow_yaml): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(sample_workflow_yaml) + errors = validate_workflow(definition) + assert errors == [] + + def test_missing_id(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + name: "Test" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("workflow.id" in e for e in errors) + + def test_invalid_id_format(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "Invalid ID!" + name: "Test" + version: "1.0.0" +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("lowercase alphanumeric" in e for e in errors) + + def test_no_steps(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: [] +""") + errors = validate_workflow(definition) + assert any("no steps" in e.lower() for e in errors) + + def test_duplicate_step_ids(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: same-id + command: speckit.specify + - id: same-id + command: speckit.plan +""") + errors = validate_workflow(definition) + assert any("Duplicate" in e for e in errors) + + def test_invalid_step_type(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: bad + type: nonexistent +""") + errors = validate_workflow(definition) + assert any("invalid type" in e.lower() for e in errors) + + def test_nested_step_validation(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +steps: + - id: branch + type: if + condition: "{{ true }}" + then: + - id: nested-a + command: speckit.specify + else: + - id: nested-b + command: speckit.plan +""") + errors = validate_workflow(definition) + assert errors == [] + + def test_invalid_input_type(self): + from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow + + definition = WorkflowDefinition.from_string(""" +workflow: + id: "test" + name: "Test" + version: "1.0.0" +inputs: + bad: + type: array +steps: + - id: step-one + command: speckit.specify +""") + errors = validate_workflow(definition) + assert any("invalid type" in e.lower() for e in errors) + + +# ===== Workflow Engine Tests ===== + +class TestWorkflowEngine: + """Test WorkflowEngine execution.""" + + def test_load_from_file(self, sample_workflow_file, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + definition = engine.load_workflow(str(sample_workflow_file)) + assert definition.id == "test-workflow" + + def test_load_from_installed_id(self, sample_workflow_file, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + definition = engine.load_workflow("test-workflow") + assert definition.id == "test-workflow" + + def test_load_not_found(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + with pytest.raises(FileNotFoundError): + engine.load_workflow("nonexistent") + + def test_execute_simple_workflow(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "simple" + name: "Simple" + version: "1.0.0" + integration: claude +inputs: + name: + type: string + default: "test" +steps: + - id: step-one + command: speckit.specify + input: + args: "{{ inputs.name }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition, {"name": "login"}) + + assert state.status == RunStatus.FAILED + assert "step-one" in state.step_results + assert state.step_results["step-one"]["output"]["command"] == "speckit.specify" + assert state.step_results["step-one"]["output"]["input"]["args"] == "login" + + def test_execute_with_gate_pauses(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "gated" + name: "Gated" + version: "1.0.0" +steps: + - id: step-one + type: shell + run: "echo test" + - id: gate + type: gate + message: "Review?" + options: [approve, reject] + on_reject: abort + - id: step-two + type: shell + run: "echo done" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.PAUSED + assert "gate" in state.step_results + assert state.step_results["gate"]["status"] == "paused" + + def test_execute_with_shell_step(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "shell-test" + name: "Shell Test" + version: "1.0.0" +steps: + - id: echo + type: shell + run: "echo workflow-output" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.COMPLETED + assert "workflow-output" in state.step_results["echo"]["output"]["stdout"] + + def test_execute_with_if_then(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "branching" + name: "Branching" + version: "1.0.0" +inputs: + scope: + type: string + default: "full" +steps: + - id: check + type: if + condition: "{{ inputs.scope == 'full' }}" + then: + - id: full-tasks + type: shell + run: "echo full" + else: + - id: partial-tasks + type: shell + run: "echo partial" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition, {"scope": "full"}) + + assert state.status == RunStatus.COMPLETED + assert "full-tasks" in state.step_results + assert "partial-tasks" not in state.step_results + + def test_execute_missing_required_input(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "needs-input" + name: "Needs Input" + version: "1.0.0" +inputs: + name: + type: string + required: true +steps: + - id: step-one + command: speckit.specify + input: + args: "{{ inputs.name }}" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + + with pytest.raises(ValueError, match="Required input"): + engine.execute(definition, {}) + + +# ===== State Persistence Tests ===== + +class TestRunState: + """Test RunState persistence and loading.""" + + def test_save_and_load(self, project_dir): + from specify_cli.workflows.engine import RunState + from specify_cli.workflows.base import RunStatus + + state = RunState( + run_id="test-run", + workflow_id="test-workflow", + project_root=project_dir, + ) + state.status = RunStatus.RUNNING + state.inputs = {"name": "login"} + state.step_results = { + "step-one": { + "output": {"file": "spec.md"}, + "status": "completed", + } + } + state.save() + + loaded = RunState.load("test-run", project_dir) + assert loaded.run_id == "test-run" + assert loaded.workflow_id == "test-workflow" + assert loaded.status == RunStatus.RUNNING + assert loaded.inputs == {"name": "login"} + assert "step-one" in loaded.step_results + + def test_load_not_found(self, project_dir): + from specify_cli.workflows.engine import RunState + + with pytest.raises(FileNotFoundError): + RunState.load("nonexistent", project_dir) + + def test_append_log(self, project_dir): + from specify_cli.workflows.engine import RunState + + state = RunState( + run_id="log-test", + workflow_id="test", + project_root=project_dir, + ) + state.append_log({"event": "test_event", "data": "hello"}) + + log_file = state.runs_dir / "log.jsonl" + assert log_file.exists() + lines = log_file.read_text().strip().split("\n") + entry = json.loads(lines[0]) + assert entry["event"] == "test_event" + assert "timestamp" in entry + + +class TestListRuns: + """Test listing workflow runs.""" + + def test_list_empty(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine + + engine = WorkflowEngine(project_dir) + assert engine.list_runs() == [] + + def test_list_after_execution(self, project_dir): + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "list-test" + name: "List Test" + version: "1.0.0" +steps: + - id: step-one + type: shell + run: "echo test" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + engine.execute(definition) + + runs = engine.list_runs() + assert len(runs) == 1 + assert runs[0]["workflow_id"] == "list-test" + + +# ===== Workflow Registry Tests ===== + +class TestWorkflowRegistry: + """Test WorkflowRegistry operations.""" + + def test_add_and_get(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test", "version": "1.0.0"}) + + entry = registry.get("test-wf") + assert entry is not None + assert entry["name"] == "Test" + assert "installed_at" in entry + + def test_remove(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("test-wf", {"name": "Test"}) + assert registry.is_installed("test-wf") + + registry.remove("test-wf") + assert not registry.is_installed("test-wf") + + def test_list(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + registry.add("wf-a", {"name": "A"}) + registry.add("wf-b", {"name": "B"}) + + installed = registry.list() + assert "wf-a" in installed + assert "wf-b" in installed + + def test_is_installed(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry = WorkflowRegistry(project_dir) + assert not registry.is_installed("missing") + + registry.add("exists", {"name": "Exists"}) + assert registry.is_installed("exists") + + def test_persistence(self, project_dir): + from specify_cli.workflows.catalog import WorkflowRegistry + + registry1 = WorkflowRegistry(project_dir) + registry1.add("test-wf", {"name": "Test"}) + + # Load fresh + registry2 = WorkflowRegistry(project_dir) + assert registry2.is_installed("test-wf") + + +# ===== Workflow Catalog Tests ===== + +class TestWorkflowCatalog: + """Test WorkflowCatalog catalog resolution.""" + + def test_default_catalogs(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + entries = catalog.get_active_catalogs() + assert len(entries) == 2 + assert entries[0].name == "default" + assert entries[1].name == "community" + + def test_env_var_override(self, project_dir, monkeypatch): + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.setenv("SPECKIT_WORKFLOW_CATALOG_URL", "https://example.com/catalog.json") + catalog = WorkflowCatalog(project_dir) + entries = catalog.get_active_catalogs() + assert len(entries) == 1 + assert entries[0].name == "env-override" + assert entries[0].url == "https://example.com/catalog.json" + + def test_project_level_config(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + config_path.write_text(yaml.dump({ + "catalogs": [{ + "name": "custom", + "url": "https://example.com/wf-catalog.json", + "priority": 1, + "install_allowed": True, + }] + })) + + catalog = WorkflowCatalog(project_dir) + entries = catalog.get_active_catalogs() + assert len(entries) == 1 + assert entries[0].name == "custom" + + def test_validate_url_http_rejected(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + catalog = WorkflowCatalog(project_dir) + with pytest.raises(WorkflowValidationError, match="HTTPS"): + catalog._validate_catalog_url("http://evil.com/catalog.json") + + def test_validate_url_localhost_http_allowed(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + # Should not raise + catalog._validate_catalog_url("http://localhost:8080/catalog.json") + + def test_add_catalog(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/new-catalog.json", "my-catalog") + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + assert config_path.exists() + data = yaml.safe_load(config_path.read_text()) + assert len(data["catalogs"]) == 1 + assert data["catalogs"][0]["url"] == "https://example.com/new-catalog.json" + + def test_add_catalog_duplicate_rejected(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/catalog.json") + + with pytest.raises(WorkflowValidationError, match="already configured"): + catalog.add_catalog("https://example.com/catalog.json") + + def test_remove_catalog(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/c1.json", "first") + catalog.add_catalog("https://example.com/c2.json", "second") + + removed = catalog.remove_catalog(0) + assert removed == "first" + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + data = yaml.safe_load(config_path.read_text()) + assert len(data["catalogs"]) == 1 + + def test_remove_catalog_invalid_index(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog("https://example.com/c1.json") + + with pytest.raises(WorkflowValidationError, match="out of range"): + catalog.remove_catalog(5) + + def test_get_catalog_configs(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + configs = catalog.get_catalog_configs() + assert len(configs) == 2 + assert configs[0]["name"] == "default" + assert isinstance(configs[0]["install_allowed"], bool) + + +# ===== Integration Test ===== + +class TestWorkflowIntegration: + """End-to-end workflow execution tests.""" + + def test_full_sequential_workflow(self, project_dir): + """Execute a multi-step sequential workflow end to end.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "e2e-test" + name: "E2E Test" + version: "1.0.0" + integration: claude +inputs: + feature: + type: string + default: "login" +steps: + - id: specify + type: shell + run: "echo speckit.specify {{ inputs.feature }}" + + - id: check-scope + type: if + condition: "{{ inputs.feature == 'login' }}" + then: + - id: echo-full + type: shell + run: "echo full scope" + else: + - id: echo-partial + type: shell + run: "echo partial scope" + + - id: plan + type: shell + run: "echo speckit.plan" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.COMPLETED + assert "specify" in state.step_results + assert "check-scope" in state.step_results + assert "echo-full" in state.step_results + assert "echo-partial" not in state.step_results + assert "plan" in state.step_results + + def test_switch_workflow(self, project_dir): + """Test switch step type in a workflow.""" + from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition + from specify_cli.workflows.base import RunStatus + + yaml_str = """ +schema_version: "1.0" +workflow: + id: "switch-test" + name: "Switch Test" + version: "1.0.0" +inputs: + action: + type: string + default: "plan" +steps: + - id: route + type: switch + expression: "{{ inputs.action }}" + cases: + specify: + - id: do-specify + type: shell + run: "echo specify" + plan: + - id: do-plan + type: shell + run: "echo plan" + default: + - id: do-default + type: shell + run: "echo default" +""" + definition = WorkflowDefinition.from_string(yaml_str) + engine = WorkflowEngine(project_dir) + state = engine.execute(definition) + + assert state.status == RunStatus.COMPLETED + assert "do-plan" in state.step_results + assert "do-specify" not in state.step_results diff --git a/workflows/ARCHITECTURE.md b/workflows/ARCHITECTURE.md new file mode 100644 index 0000000000..892333473c --- /dev/null +++ b/workflows/ARCHITECTURE.md @@ -0,0 +1,211 @@ +# Workflow System Architecture + +This document describes the internal architecture of the workflow engine — how definitions are parsed, steps are dispatched, state is persisted, and catalogs are resolved. + +For usage instructions, see [README.md](README.md). + +## Execution Model + +When `specify workflow run` is invoked, the engine loads a YAML definition, resolves inputs, and dispatches steps sequentially through the step registry: + +```mermaid +flowchart TD + A["specify workflow run my-workflow"] --> B["WorkflowEngine.load_workflow()"] + B --> C["WorkflowDefinition.from_yaml()"] + C --> D["_resolve_inputs()"] + D --> E["validate_workflow()"] + E --> F["RunState.create()"] + F --> G["_execute_steps()"] + G --> H{Step type?} + H -- command --> I["CommandStep.execute()"] + H -- shell --> J["ShellStep.execute()"] + H -- gate --> K["GateStep.execute()"] + H -- "if" --> L["IfThenStep.execute()"] + H -- switch --> M["SwitchStep.execute()"] + H -- "while/do-while" --> N["Loop steps"] + H -- "fan-out/fan-in" --> O["Fan-out/fan-in"] + + I --> P{Result status?} + J --> P + K --> P + L --> P + M --> P + N --> P + O --> P + P -- COMPLETED --> Q{Has next_steps?} + P -- PAUSED --> R["Save state → exit"] + P -- FAILED --> S["Log error → exit"] + Q -- Yes --> G + Q -- No --> T{More steps?} + T -- Yes --> G + T -- No --> U["Status = COMPLETED"] + + style R fill:#ff9800,color:#fff + style S fill:#f44336,color:#fff + style U fill:#4caf50,color:#fff +``` + +### Sequential Execution + +Steps execute sequentially. Each step receives a `StepContext` containing resolved inputs, accumulated step results, and workflow-level defaults. After execution, the step's output is stored in `context.steps[step_id]` and made available to subsequent steps via expressions like `{{ steps.specify.output.file }}`. + +### Nested Steps (Control Flow) + +Steps like `if`, `switch`, `while`, and `do-while` return `next_steps` — inline step definitions that the engine executes recursively via `_execute_steps()`. Nested steps share the same `StepContext` and `RunState`, so their outputs are visible to later top-level steps. + +### State Persistence and Resume + +The engine saves `RunState` to disk after each step, enabling resume from the exact point of interruption: + +```mermaid +flowchart LR + A["CREATED"] --> B["RUNNING"] + B --> C["COMPLETED"] + B --> D["PAUSED"] + B --> E["FAILED"] + B --> F["ABORTED"] + D -- "resume()" --> B + E -- "resume()" --> B +``` + +When a `gate` step pauses execution, the engine persists `current_step_index` and all accumulated `step_results`. On `specify workflow resume `, the engine restores the context and continues from the paused step. + +> **Note:** Resume tracking is at the top-level step index only. If a +> nested step (inside `if`/`switch`/`while`) pauses, resume re-runs +> the parent control-flow step and its nested body. A nested step-path +> stack for exact resume is a planned enhancement. + +## Step Types + +The engine ships with 10 built-in step types, each in its own subpackage under `src/specify_cli/workflows/steps/`: + +| Type Key | Class | Purpose | Returns `next_steps`? | +|----------|-------|---------|-----------------------| +| `command` | `CommandStep` | Invoke an installed Spec Kit command via integration CLI | No | +| `prompt` | `PromptStep` | Send an arbitrary inline prompt to integration CLI | No | +| `shell` | `ShellStep` | Run a shell command, capture output | No | +| `gate` | `GateStep` | Interactive human review/approval | No (pauses in CI) | +| `if` | `IfThenStep` | Conditional branching (then/else) | Yes | +| `switch` | `SwitchStep` | Multi-branch dispatch on expression | Yes | +| `while` | `WhileStep` | Loop while condition is truthy | Yes (if true) | +| `do-while` | `DoWhileStep` | Loop, always runs body at least once | Yes (always) | +| `fan-out` | `FanOutStep` | Dispatch per item over a collection | No (engine expands) | +| `fan-in` | `FanInStep` | Aggregate results from fan-out | No | + +## Step Registry + +All step types register into `STEP_REGISTRY` via `_register_builtin_steps()` in `src/specify_cli/workflows/__init__.py`. The registry maps `type_key` strings to step instances: + +```python +STEP_REGISTRY: dict[str, StepBase] # e.g., {"command": CommandStep(), "gate": GateStep(), ...} +``` + +Registration is explicit — each step class is imported and instantiated. New step types follow the same pattern: subclass `StepBase`, set `type_key`, implement `execute()` and optionally `validate()`. + +## Expression Engine + +Workflow definitions use Jinja2-like `{{ expression }}` syntax for dynamic values. The expression engine in `src/specify_cli/workflows/expressions.py` supports: + +| Feature | Syntax | Example | +|---------|--------|---------| +| Variable access | `{{ inputs.name }}` | Dot-path traversal into context | +| Step outputs | `{{ steps.plan.output.file }}` | Access previous step results | +| Comparisons | `==`, `!=`, `>`, `<`, `>=`, `<=` | `{{ count > 5 }}` | +| Boolean logic | `and`, `or`, `not` | `{{ items and status == 'ok' }}` | +| Membership | `in`, `not in` | `{{ 'error' not in status }}` | +| Literals | strings, numbers, booleans, lists | `{{ true }}`, `{{ [1, 2] }}` | +| Filter: `default` | `{{ val \| default('fallback') }}` | Fallback for None/empty | +| Filter: `join` | `{{ list \| join(', ') }}` | Join list elements | +| Filter: `contains` | `{{ text \| contains('sub') }}` | Substring/membership check | +| Filter: `map` | `{{ list \| map('attr') }}` | Extract attribute from each item | + +**Single expressions** (`{{ expr }}` only) return typed values. **Mixed templates** (`"text {{ expr }} more"`) return interpolated strings. + +### Namespace + +The expression evaluator builds a namespace from the `StepContext`: + +| Key | Source | Available when | +|-----|--------|----------------| +| `inputs` | Resolved workflow inputs | Always | +| `steps` | Accumulated step results | After first step | +| `item` | Current iteration item | Inside fan-out | +| `fan_in` | Aggregated results | Inside fan-in | + +## Input Resolution + +When a workflow is executed, `_resolve_inputs()` validates and coerces provided values against the `inputs:` schema: + +| Declared Type | Coercion | Example | +|---------------|----------|---------| +| `string` | None (pass-through) | `"my-feature"` | +| `number` | `float()` → `int()` if whole | `"42"` → `42` | +| `boolean` | `"true"/"1"/"yes"` → `True` | `"false"` → `False` | +| `enum` | Validates against allowed values | `["full", "backend-only"]` | + +Missing required inputs raise `ValueError`. Inputs with `default` values use the default when not provided. + +## Catalog System + +```mermaid +flowchart TD + A["specify workflow search"] --> B["WorkflowCatalog.get_active_catalogs()"] + B --> C{SPECKIT_WORKFLOW_CATALOG_URL set?} + C -- Yes --> D["Single custom catalog"] + C -- No --> E{.specify/workflow-catalogs.yml exists?} + E -- Yes --> F["Project-level catalog stack"] + E -- No --> G{"~/.specify/workflow-catalogs.yml exists?"} + G -- Yes --> H["User-level catalog stack"] + G -- No --> I["Built-in defaults"] + I --> J["default (install allowed)"] + I --> K["community (discovery only)"] + + style D fill:#ff9800,color:#fff + style F fill:#2196f3,color:#fff + style H fill:#2196f3,color:#fff + style J fill:#4caf50,color:#fff + style K fill:#9e9e9e,color:#fff +``` + +Catalogs are fetched with a 1-hour cache (per-URL, SHA256-hashed cache files in `.specify/workflows/.cache/`). Each catalog entry has a `priority` (for merge ordering) and `install_allowed` flag. + +When `specify workflow add ` installs from catalog, it downloads the workflow YAML from the catalog entry's `url` field into `.specify/workflows//workflow.yml`. + +## State and Configuration Locations + +| Component | Location | Format | Purpose | +|-----------|----------|--------|---------| +| Workflow definitions | `.specify/workflows/{id}/workflow.yml` | YAML | Installed workflow definitions | +| Workflow registry | `.specify/workflows/workflow-registry.json` | JSON | Installed workflows metadata | +| Run state | `.specify/workflows/runs/{run_id}/state.json` | JSON | Persisted execution state | +| Run inputs | `.specify/workflows/runs/{run_id}/inputs.json` | JSON | Resolved input values | +| Run log | `.specify/workflows/runs/{run_id}/log.jsonl` | JSONL | Append-only event log | +| Catalog cache | `.specify/workflows/.cache/*.json` | JSON | Cached catalog entries (1hr TTL) | +| Project catalogs | `.specify/workflow-catalogs.yml` | YAML | Project-level catalog sources | +| User catalogs | `~/.specify/workflow-catalogs.yml` | YAML | User-level catalog sources | + +## Module Structure + +``` +src/specify_cli/ +├── workflows/ +│ ├── __init__.py # STEP_REGISTRY + _register_builtin_steps() +│ ├── base.py # StepBase, StepContext, StepResult, StepStatus, RunStatus +│ ├── catalog.py # WorkflowCatalog, WorkflowCatalogEntry, WorkflowRegistry +│ ├── engine.py # WorkflowDefinition, WorkflowEngine, RunState, validate_workflow() +│ ├── expressions.py # evaluate_expression(), evaluate_condition(), filters +│ └── steps/ +│ ├── command/ # Dispatch command to AI integration +│ ├── shell/ # Run shell command +│ ├── gate/ # Human review checkpoint +│ ├── if_then/ # Conditional branching +│ ├── prompt/ # Arbitrary inline prompts +│ ├── switch/ # Multi-branch dispatch +│ ├── while_loop/ # While loop +│ ├── do_while/ # Do-while loop +│ ├── fan_out/ # Sequential per-item dispatch +│ └── fan_in/ # Result aggregation +└── __init__.py # CLI commands: specify workflow run/resume/status/ + # list/add/remove/search/info, + # specify workflow catalog list/add/remove +``` diff --git a/workflows/PUBLISHING.md b/workflows/PUBLISHING.md new file mode 100644 index 0000000000..857aaf7d11 --- /dev/null +++ b/workflows/PUBLISHING.md @@ -0,0 +1,285 @@ +# Workflow Publishing Guide + +This guide explains how to publish your workflow to the Spec Kit workflow catalog, making it discoverable by `specify workflow search`. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Prepare Your Workflow](#prepare-your-workflow) +3. [Submit to Catalog](#submit-to-catalog) +4. [Verification Process](#verification-process) +5. [Release Workflow](#release-workflow) +6. [Best Practices](#best-practices) + +--- + +## Prerequisites + +Before publishing a workflow, ensure you have: + +1. **Valid Workflow**: A working `workflow.yml` that passes `specify workflow run` validation +2. **Git Repository**: Workflow hosted on GitHub (or other public git hosting) +3. **Documentation**: README.md with description, inputs, and step graph +4. **License**: Open source license file (MIT, Apache 2.0, etc.) +5. **Versioning**: Semantic versioning in the `workflow.version` field +6. **Testing**: Workflow tested on real projects + +--- + +## Prepare Your Workflow + +### 1. Workflow Structure + +Host your workflow in a repository with this structure: + +```text +your-workflow/ +├── workflow.yml # Required: Workflow definition +├── README.md # Required: Documentation +├── LICENSE # Required: License file +└── CHANGELOG.md # Recommended: Version history +``` + +### 2. workflow.yml Validation + +Verify your definition is valid: + +```yaml +schema_version: "1.0" + +workflow: + id: "your-workflow" # Unique lowercase-hyphenated ID + name: "Your Workflow Name" # Human-readable name + version: "1.0.0" # Semantic version + author: "Your Name or Organization" + description: "Brief description (one sentence)" + integration: claude # Default integration (optional) + model: "claude-sonnet-4-20250514" # Default model (optional) + +requires: + speckit_version: ">=0.6.1" + integrations: + any: ["claude", "gemini"] # At least one required + +inputs: + feature_name: + type: string + required: true + prompt: "Feature name" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + input: + args: "{{ inputs.feature_name }}" + + - id: review + type: gate + message: "Review the output." + options: [approve, reject] + on_reject: abort +``` + +**Validation Checklist**: + +- ✅ `id` is lowercase alphanumeric with hyphens (single-character IDs are allowed) +- ✅ `version` follows semantic versioning (X.Y.Z) +- ✅ `description` is concise +- ✅ All step IDs are unique +- ✅ Step types are valid: `command`, `prompt`, `shell`, `gate`, `if`, `switch`, `while`, `do-while`, `fan-out`, `fan-in` +- ✅ Required fields present per step type (e.g., `condition` for `if`, `expression` for `switch`) +- ✅ Input types are valid: `string`, `number`, `boolean` +- ✅ Step IDs do not contain `:` (reserved for engine-generated nested IDs like `parentId:childId`) + +### 3. Test Locally + +```bash +# Run with required inputs +specify workflow run ./workflow.yml --input feature_name="user-auth" + +# Check validation +specify workflow info ./workflow.yml + +# Resume after a gate pause +specify workflow resume + +# Check run status +specify workflow status +``` + +### 4. Create GitHub Release + +Create a GitHub release for your workflow version: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +The raw YAML URL will be: + +```text +https://raw.githubusercontent.com/your-org/spec-kit-workflow-your-workflow/v1.0.0/workflow.yml +``` + +### 5. Test Installation from URL + +```bash +specify workflow add your-workflow +# (once published to catalog) +``` + +--- + +## Submit to Catalog + +### Understanding the Catalogs + +Spec Kit uses a dual-catalog system: + +- **`catalog.json`** — Official, verified workflows (install allowed by default) +- **`catalog.community.json`** — Community-contributed workflows (discovery only by default) + +All community workflows should be submitted to `catalog.community.json`. + +### 1. Fork the spec-kit Repository + +```bash +git clone https://github.com/YOUR-USERNAME/spec-kit.git +cd spec-kit +``` + +### 2. Add Workflow to Community Catalog + +Edit `workflows/catalog.community.json` and add your workflow. + +> **⚠️ Entries must be sorted alphabetically by workflow ID.** Insert your workflow in the correct position within the `"workflows"` object. + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-04-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json", + "workflows": { + "your-workflow": { + "id": "your-workflow", + "name": "Your Workflow Name", + "description": "Brief description of what your workflow automates", + "author": "Your Name", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/your-org/spec-kit-workflow-your-workflow/v1.0.0/workflow.yml", + "repository": "https://github.com/your-org/spec-kit-workflow-your-workflow", + "license": "MIT", + "requires": { + "speckit_version": ">=0.15.0" + }, + "tags": [ + "category", + "automation" + ], + "created_at": "2026-04-10T00:00:00Z", + "updated_at": "2026-04-10T00:00:00Z" + } + } +} +``` + +### 3. Submit Pull Request + +```bash +git checkout -b add-your-workflow +git add workflows/catalog.community.json +git commit -m "Add your-workflow to community catalog + +- Workflow ID: your-workflow +- Version: 1.0.0 +- Author: Your Name +- Description: Brief description +" +git push origin add-your-workflow +``` + +**Pull Request Checklist**: + +```markdown +## Workflow Submission + +**Workflow Name**: Your Workflow Name +**Workflow ID**: your-workflow +**Version**: 1.0.0 +**Repository**: https://github.com/your-org/spec-kit-workflow-your-workflow + +### Checklist +- [ ] Valid workflow.yml (passes `specify workflow info`) +- [ ] README.md with description, inputs, and step graph +- [ ] LICENSE file included +- [ ] GitHub release created with raw YAML URL +- [ ] Workflow tested end-to-end with `specify workflow run` +- [ ] All gate steps have clear review messages +- [ ] Input prompts are descriptive +- [ ] Added to workflows/catalog.community.json (alphabetical order) +``` + +--- + +## Verification Process + +After submission, maintainers will review: + +1. **Definition validation** — valid `workflow.yml`, correct schema +2. **Step correctness** — all step types used correctly, no dangling references +3. **Input design** — clear prompts, sensible defaults and enums +4. **Security** — no malicious shell commands, safe operations +5. **Documentation** — clear README explaining what the workflow does and when to use it + +Once verified, the workflow appears in `specify workflow search`. + +--- + +## Release Workflow + +When releasing a new version: + +1. Update `version` in `workflow.yml` +2. Update CHANGELOG.md +3. Tag and push: `git tag v1.1.0 && git push origin v1.1.0` +4. Submit PR to update `version` and `url` in `workflows/catalog.community.json` + +--- + +## Best Practices + +### Step Design + +- **Use gates at decision points** — place `gate` steps after each major output so users can review before proceeding +- **Keep steps focused** — each step should do one thing; prefer more steps over complex single steps +- **Provide clear gate messages** — explain what to review and what approve/reject means + +### Inputs + +- **Use descriptive prompts** — the `prompt` field is shown to users when running the workflow +- **Set sensible defaults** — optional inputs should have defaults that work for the common case +- **Constrain with enums** — when there's a fixed set of valid values, use `enum` for validation +- **Type appropriately** — use `number` for counts, `boolean` for flags, `string` for names + +### Shell Steps + +- **Avoid destructive commands** — don't delete files or directories without explicit confirmation via a gate +- **Quote variables** — use proper quoting in shell commands to handle spaces +- **Check exit codes** — shell step failures stop the workflow; make sure commands are robust + +### Integration Flexibility + +- **Set `integration` at workflow level** — use the `workflow.integration` field as the default +- **Allow per-step overrides** — let individual steps specify a different integration if needed +- **Document required integrations** — list which integrations must be installed in `requires.integrations` + +### Expression References + +- **Only reference prior steps** — expressions like `{{ steps.plan.output.file }}` only work if `plan` ran before the current step +- **Use `default` filter** — `{{ val | default('fallback') }}` prevents failures from missing values +- **Keep expressions simple** — complex logic should be in shell steps, not expressions diff --git a/workflows/README.md b/workflows/README.md new file mode 100644 index 0000000000..3ece00b6b0 --- /dev/null +++ b/workflows/README.md @@ -0,0 +1,339 @@ +# Workflows + +Workflows are multi-step, resumable automation pipelines defined in YAML. They orchestrate Spec Kit commands across integrations, evaluate control flow, and pause at human review gates — enabling end-to-end Spec-Driven Development cycles without manual step-by-step invocation. + +## How It Works + +A workflow definition declares a sequence of steps. The engine executes them in order, dispatching commands to AI integrations, running shell commands, evaluating conditions for branching, and pausing at gates for human review. State is persisted after each step, so workflows can be resumed after interruption. + +```yaml +steps: + - id: specify + command: speckit.specify + input: + args: "{{ inputs.feature_name }}" + + - id: review + type: gate + message: "Review the spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan +``` + +For detailed architecture and internals, see [ARCHITECTURE.md](ARCHITECTURE.md). + +## Quick Start + +```bash +# Search available workflows +specify workflow search + +# Install the built-in SDD workflow +specify workflow add speckit + +# Or run directly from a local YAML file +specify workflow run ./workflow.yml --input feature_name="user-auth" + +# Run an installed workflow with inputs +specify workflow run speckit --input feature_name="user-auth" + +# Check run status +specify workflow status + +# Resume after a gate pause +specify workflow resume + +# Get detailed workflow info +specify workflow info speckit + +# Remove a workflow +specify workflow remove speckit +``` + +## Running Workflows + +### From an Installed Workflow + +```bash +specify workflow add speckit +specify workflow run speckit --input feature_name="user-auth" +``` + +### From a Local YAML File + +```bash +specify workflow run ./my-workflow.yml --input feature_name="user-auth" +``` + +### Multiple Inputs + +```bash +specify workflow run speckit \ + --input feature_name="user-auth" \ + --input scope="backend-only" +``` + +## Step Types + +Workflows support 10 built-in step types: + +### Command Steps (default) + +Invoke an installed Spec Kit command by name via the integration CLI: + +```yaml +- id: specify + command: speckit.specify + input: + args: "{{ inputs.feature_name }}" + integration: claude # Optional: override workflow default + model: "claude-sonnet-4-20250514" # Optional: override model +``` + +### Prompt Steps + +Send an arbitrary inline prompt to an integration CLI (no command file needed): + +```yaml +- id: security-review + type: prompt + prompt: "Review {{ inputs.file }} for security vulnerabilities" + integration: claude +``` + +### Shell Steps + +Run a shell command and capture output: + +```yaml +- id: run-tests + type: shell + run: "cd {{ inputs.project_dir }} && npm test" +``` + +### Gate Steps + +Pause for human review. The workflow resumes when `specify workflow resume` is called: + +```yaml +- id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, edit, reject] + on_reject: abort +``` + +### If/Then/Else Steps + +Conditional branching based on an expression: + +```yaml +- id: check-scope + type: if + condition: "{{ inputs.scope == 'full' }}" + then: + - id: full-plan + command: speckit.plan + else: + - id: quick-plan + command: speckit.plan + options: + quick: true +``` + +### Switch Steps + +Multi-branch dispatch on an expression value: + +```yaml +- id: route + type: switch + expression: "{{ steps.review.output.choice }}" + cases: + approve: + - id: plan + command: speckit.plan + reject: + - id: log + type: shell + run: "echo 'Rejected'" + default: + - id: fallback + type: gate + message: "Unexpected choice" +``` + +### While Loop Steps + +Repeat steps while a condition is truthy: + +```yaml +- id: retry + type: while + condition: "{{ steps.run-tests.output.exit_code != 0 }}" + max_iterations: 5 + steps: + - id: fix + command: speckit.implement +``` + +### Do-While Loop Steps + +Execute steps at least once, then repeat while condition holds: + +```yaml +- id: refine + type: do-while + condition: "{{ steps.review.output.choice == 'edit' }}" + max_iterations: 3 + steps: + - id: revise + command: speckit.specify +``` + +### Fan-Out Steps + +Dispatch a step template for each item in a collection (sequential): + +```yaml +- id: parallel-impl + type: fan-out + items: "{{ steps.tasks.output.task_list }}" + max_concurrency: 3 + step: + id: impl + command: speckit.implement +``` + +### Fan-In Steps + +Aggregate results from fan-out steps: + +```yaml +- id: collect + type: fan-in + wait_for: [parallel-impl] + output: {} +``` + +## Expressions + +Workflow definitions use `{{ expression }}` syntax for dynamic values: + +```yaml +# Access inputs +args: "{{ inputs.feature_name }}" + +# Access previous step outputs +args: "{{ steps.specify.output.file }}" + +# Comparisons +condition: "{{ steps.run-tests.output.exit_code != 0 }}" + +# Filters +message: "{{ status | default('pending') }}" +``` + +Supported filters: `default`, `join`, `contains`, `map`. + +## Input Types + +Workflow inputs are type-checked and coerced from CLI string values: + +```yaml +inputs: + feature_name: + type: string + required: true + prompt: "Feature name" + task_count: + type: number + default: 5 + dry_run: + type: boolean + default: false + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] +``` + +| Type | Accepts | Example | +|------|---------|---------| +| `string` | Any string | `"user-auth"` | +| `number` | Numeric strings → int/float | `"42"` → `42` | +| `boolean` | `true`/`1`/`yes` → `True`, `false`/`0`/`no` → `False` | `"true"` → `True` | + +## State and Resume + +Every workflow run persists state to `.specify/workflows/runs//`: + +```bash +# List all runs with status +specify workflow status + +# Check a specific run +specify workflow status + +# Resume a paused run (after approving a gate) +specify workflow resume + +# Resume a failed run (retries from the failed step) +specify workflow resume +``` + +Run states: `created` → `running` → `completed` | `paused` | `failed` | `aborted` + +## Catalog Management + +Workflows are discovered through catalogs. By default, Spec Kit uses the official and community catalogs: + +> [!NOTE] +> Community workflows are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting and structure, but they do **not review, audit, endorse, or support the workflow definitions themselves**. Review workflow source before installation and use at your own discretion. + +```bash +# List active catalogs +specify workflow catalog list + +# Add a custom catalog +specify workflow catalog add https://example.com/catalog.json --name my-org + +# Remove a catalog +specify workflow catalog remove +``` + +## Creating a Workflow + +1. Create a `workflow.yml` following the schema above +2. Test locally with `specify workflow run ./workflow.yml --input key=value` +3. Verify with `specify workflow info ./workflow.yml` +4. See [PUBLISHING.md](PUBLISHING.md) to submit to the catalog + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `SPECKIT_WORKFLOW_CATALOG_URL` | Override the catalog URL (replaces all defaults) | + +## Configuration Files + +| File | Scope | Description | +|------|-------|-------------| +| `.specify/workflow-catalogs.yml` | Project | Custom catalog stack for this project | +| `~/.specify/workflow-catalogs.yml` | User | Custom catalog stack for all projects | + +## Repository Layout + +``` +workflows/ +├── ARCHITECTURE.md # Internal architecture documentation +├── PUBLISHING.md # Guide for submitting workflows to the catalog +├── README.md # This file +├── catalog.json # Official workflow catalog +├── catalog.community.json # Community workflow catalog +└── speckit/ # Built-in SDD cycle workflow + └── workflow.yml +``` diff --git a/workflows/catalog.community.json b/workflows/catalog.community.json new file mode 100644 index 0000000000..c654f5ed22 --- /dev/null +++ b/workflows/catalog.community.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-10T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json", + "workflows": {} +} diff --git a/workflows/catalog.json b/workflows/catalog.json new file mode 100644 index 0000000000..967120afb0 --- /dev/null +++ b/workflows/catalog.json @@ -0,0 +1,16 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-13T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.json", + "workflows": { + "speckit": { + "id": "speckit", + "name": "Full SDD Cycle", + "description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates", + "author": "GitHub", + "version": "1.0.0", + "url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/speckit/workflow.yml", + "tags": ["sdd", "full-cycle"] + } + } +} diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml new file mode 100644 index 0000000000..a440c5c507 --- /dev/null +++ b/workflows/speckit/workflow.yml @@ -0,0 +1,63 @@ +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + speckit_version: ">=0.6.1" + integrations: + any: ["copilot", "claude", "gemini"] + +inputs: + feature_name: + type: string + required: true + prompt: "Feature name" + integration: + type: string + default: "copilot" + prompt: "Integration to use (e.g. claude, copilot, gemini)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.feature_name }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.feature_name }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.feature_name }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.feature_name }}" From 3467d26b1c1b2ed5d6aeb84fdbdac732808d522b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:06:06 -0500 Subject: [PATCH 045/184] chore: release 0.7.0, begin 0.7.1.dev0 development (#2217) * chore: bump version to 0.7.0 * chore: begin 0.7.1.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 928bc74b9b..fe587098f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ +## [0.7.0] - 2026-04-14 + +### Changed + +- Add workflow engine with catalog system (#2158) +- docs(catalog): add claude-ask-questions to community preset catalog (#2191) +- Add SFSpeckit — Salesforce SDD Extension (#2208) +- feat(scripts): optional single-segment branch prefix for gitflow (#2202) +- chore: release 0.6.2, begin 0.6.3.dev0 development (#2205) +- Add Worktrees extension to community catalog (#2207) +- feat: Update catalog.community.json for preset-fiction-book-writing (#2199) + ## [0.6.2] - 2026-04-13 ### Changed diff --git a/pyproject.toml b/pyproject.toml index db53f2cb58..e7ea248214 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.6.3.dev0" +version = "0.7.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 39c7b04e5eb366a90f1e867f2d2c5196bd3a2004 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:12:27 -0500 Subject: [PATCH 046/184] chore: deprecate --ai flag in favor of --integration on specify init (#2218) * chore: deprecate --ai flag in favor of --integration on specify init - Adds deprecation warning when --ai is used - Shows equivalent --integration command replacement - Handles generic integration with --commands-dir mapping - Adds comprehensive test coverage for deprecation behavior - Warning displays as prominent red panel above Next Steps - --ai flag continues to function (non-breaking change) Fixes #2169 * Address PR review feedback for issue #2169 - Use existing strip_ansi helper from conftest instead of duplicating ANSI escape pattern - Properly escape ai_commands_dir with shlex.quote() to handle paths with spaces - Add shlex import to support proper command-line argument escaping --- src/specify_cli/__init__.py | 46 +++++++++++++++++++++++++ tests/integrations/test_cli.py | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c33281e2b4..eb4c306bf4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -33,6 +33,7 @@ import json import json5 import stat +import shlex import yaml from pathlib import Path from typing import Any, Optional @@ -92,6 +93,36 @@ def _build_ai_assistant_help() -> str: return base_help + " Use " + aliases_text + "." AI_ASSISTANT_HELP = _build_ai_assistant_help() + +def _build_integration_equivalent( + integration_key: str, + ai_commands_dir: str | None = None, +) -> str: + """Build the modern --integration equivalent for legacy --ai usage.""" + + parts = [f"--integration {integration_key}"] + if integration_key == "generic" and ai_commands_dir: + parts.append( + f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"' + ) + return " ".join(parts) + + +def _build_ai_deprecation_warning( + integration_key: str, + ai_commands_dir: str | None = None, +) -> str: + """Build the legacy --ai deprecation warning message.""" + + replacement = _build_integration_equivalent( + integration_key, + ai_commands_dir=ai_commands_dir, + ) + return ( + "[bold]--ai[/bold] is deprecated and will no longer be available in version 1.0.0 or later.\n\n" + f"Use [bold]{replacement}[/bold] instead." + ) + SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" @@ -957,6 +988,7 @@ def init( """ show_banner() + ai_deprecation_warning: str | None = None # Detect when option values are likely misinterpreted flags (parameter ordering issue) if ai_assistant and ai_assistant.startswith("--"): @@ -995,6 +1027,10 @@ def init( if not resolved_integration: console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}") raise typer.Exit(1) + ai_deprecation_warning = _build_ai_deprecation_warning( + resolved_integration.key, + ai_commands_dir=ai_commands_dir, + ) # Deprecation warnings for --ai-skills and --ai-commands-dir (only when # an integration has been resolved from --ai or --integration) @@ -1428,6 +1464,16 @@ def init( console.print() console.print(security_notice) + if ai_deprecation_warning: + deprecation_notice = Panel( + ai_deprecation_warning, + title="[bold red]Deprecation Warning[/bold red]", + border_style="red", + padding=(1, 2), + ) + console.print() + console.print(deprecation_notice) + steps_lines = [] if not here: steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 1e23e35a7d..bd73ccd664 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -5,6 +5,14 @@ import yaml +from tests.conftest import strip_ansi + + +def _normalize_cli_output(output: str) -> str: + output = strip_ansi(output) + output = " ".join(output.split()) + return output.strip() + class TestInitIntegrationFlag: def test_integration_and_ai_mutually_exclusive(self, tmp_path): @@ -77,6 +85,59 @@ def test_ai_copilot_auto_promotes(self, tmp_path): assert result.exit_code == 0 assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + def test_ai_emits_deprecation_warning_with_integration_replacement(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "warn-ai" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "copilot", "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "Deprecation Warning" in normalized_output + assert "--ai" in normalized_output + assert "deprecated" in normalized_output + assert "no longer be available" in normalized_output + assert "1.0.0" in normalized_output + assert "--integration copilot" in normalized_output + assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps") + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + + def test_ai_generic_warning_suggests_integration_options_equivalent(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "warn-generic" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "generic", "--ai-commands-dir", ".myagent/commands", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "Deprecation Warning" in normalized_output + assert "--integration generic" in normalized_output + assert "--integration-options" in normalized_output + assert ".myagent/commands" in normalized_output + assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps") + assert (project / ".myagent" / "commands" / "speckit.plan.md").exists() + def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): from typer.testing import CliRunner from specify_cli import app From f0886bd089030fc94e75899a9829cc744d766164 Mon Sep 17 00:00:00 2001 From: Umm e Habiba <161445850+UmmeHabiba1312@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:08:28 +0500 Subject: [PATCH 047/184] feat: register architect-preview in community catalog (#2214) * Add Architect Impact Previewer to catalog Added a new architect impact previewer with metadata. * Fix description formatting in architect-preview * Add Architect Impact Previewer extension details * Update catalog.community.json * Add Architect Impact Previewer extension details Added 'Architect Impact Previewer' extension with details including name, description, author, version, and URLs. * Add Architect Impact Previewer extension to README --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c729fe02f1..a8a8d3a3ed 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ The following community-contributed extensions are available in [`catalog.commun | Extension | Purpose | Category | Effect | URL | |-----------|---------|----------|--------|-----| | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | +| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 61731b22d9..dd7f4f3a16 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-13T23:01:30Z", + "updated_at": "2026-04-14T21:30:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -36,6 +36,38 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "architect-preview": { + "name": "Architect Impact Previewer", + "id": "architect-preview", + "description": "Predicts architectural impact, complexity, and risks of proposed changes before implementation.", + "author": "Umme Habiba", + "version": "1.0.0", + "download_url": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview", + "homepage": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview", + "documentation": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/blob/main/README.md", + "changelog": "https://github.com/UmmeHabiba1312/spec-kit-architect-preview/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "architecture", + "analysis", + "risk-assessment", + "planning", + "preview" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-14T00:00:00Z", + "updated_at": "2026-04-14T00:00:00Z" + }, "archive": { "name": "Archive Extension", "id": "archive", From 33a28ec8f7f87b3facce51a6226581683aaa05e1 Mon Sep 17 00:00:00 2001 From: Michal Bachorik Date: Wed, 15 Apr 2026 14:35:49 +0200 Subject: [PATCH 048/184] fix: unofficial PyPI warning (#1982) and legacy extension command name auto-correction (#2017) (#2027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: warn about unofficial PyPI packages and recommend version verification (#1982) Clarify that only packages from github/spec-kit are official, and add `specify version` as a post-install verification step to help users catch accidental installation of an unrelated package with the same name. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): auto-correct legacy command names instead of hard-failing (#2017) Community extensions that predate the strict naming requirement use two common legacy formats ('speckit.command' and 'extension.command'). Instead of rejecting them outright, auto-correct to the required 'speckit.{extension}.{command}' pattern and emit a compatibility warning so authors know they need to update their manifest. Names that cannot be safely corrected (e.g. single-segment names) still raise ValidationError. Co-Authored-By: Claude Sonnet 4.6 * fix(tests): isolate preset catalog search test from community catalog network calls test_search_with_cached_data asserted exactly 2 results but was getting 4 because _get_merged_packs() queries the full built-in catalog stack (default + community). The community catalog had no local cache and hit the network, returning real presets. Writing a project-level preset-catalogs.yml that pins the test to the default URL only makes the count assertions deterministic. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): extend auto-correction to aliases (#2017) The upstream #1994 added alias validation in _collect_manifest_command_names, which also rejected legacy 2-part alias names (e.g. 'speckit.verify'). Extend the same auto-correction logic from _validate() to cover aliases, so both 'speckit.command' and 'extension.command' alias formats are corrected to 'speckit.{ext_id}.{command}' with a compatibility warning instead of hard-failing. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): address PR review feedback (#2017) - _try_correct_command_name: only correct 'X.Y' to 'speckit.ext_id.Y' when X matches ext_id, preventing misleading warnings followed by install failure due to namespace mismatch - _validate: add aliases type/string guards matching _collect_manifest _command_names defensive checks - _validate: track command renames and rewrite any hook.*.command references that pointed at a renamed command, emitting a warning - test: fix test_command_name_autocorrect_no_speckit_prefix to use ext_id matching the legacy namespace; add namespace-mismatch test - test: replace redundant preset-catalogs.yml isolation with monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL") so the env var cannot bypass catalog restriction in CI environments Co-Authored-By: Claude Sonnet 4.6 * Update docs/installation.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(extensions): warn when hook command refs are silently canonicalized; fix grammar - Hook rewrites (alias-form or rename-map) now always emit a warning so extension authors know to update their manifests. Previously only rename-map rewrites produced a warning; pure alias-form lifts were silent. - Pluralize "command/commands" in the uninstall confirmation message so single-command extensions no longer print "1 commands". Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): raise ValidationError for non-dict hook entries Silently skipping non-dict hook entries left them in manifest.hooks, causing HookExecutor.register_hooks() to crash with AttributeError when it called hook_config.get() on a non-mapping value. Also updates PR description to accurately reflect the implementation (no separate _try_correct_alias_name helper; aliases use the same _try_correct_command_name path). Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): derive remove cmd_count from registry, fix wording Previously cmd_count used len(ext_manifest.commands) which only counted primary commands and missed aliases. The registry's registered_commands already tracks every command name (primaries + aliases) per agent, so max(len(v) for v in registered_commands.values()) gives the correct total. Also changes "from AI agent" → "across AI agents" since remove() unregisters commands from all detected agents. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): distinguish missing vs empty registered_commands in remove prompt Using get() without a default lets us tell apart: - key missing (legacy registry entry) → fall back to manifest count - key present but empty dict (installed with no agent dirs) → show 0 Previously the truthiness check `if registered_commands and ...` treated both cases the same, so an empty dict fell back to len(manifest.commands) and overcounted commands that would actually be removed. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): clarify removal prompt wording to 'per agent' 'across AI agents' implied a total count, but cmd_count uses max() across agents (per-agent count). Using sum() would double-count since users think in logical commands, not per-agent files. 'per agent' accurately describes what the number represents. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): clarify cmd_count comment — per-agent max, not total The comment said 'covers all agents' implying a total, but cmd_count uses max() across agents (per-agent count). Updated comment to explain the max() choice and why sum() would double-count. Co-Authored-By: Claude Sonnet 4.6 * test(extensions): add CLI tests for remove confirmation pluralization Adds TestExtensionRemoveCLI with two CliRunner tests: - singular: 1 registered command → '1 command per agent' - plural: 2 registered commands → '2 commands per agent' These prevent regressions on the cmd_count pluralization logic and the 'per agent' wording introduced in this PR. Co-Authored-By: Claude Sonnet 4.6 * fix(agents): remove orphaned SKILL.md parent dirs on unregister For SKILL.md-based agents (codex, kimi), each command lives in its own subdirectory (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). The previous unregister_commands() only unlinked the file, leaving an empty parent dir. Now attempts rmdir() on the parent when it differs from the agent commands dir. OSError is silenced so non-empty dirs (e.g. user files) are safely left. Adds test_unregister_skill_removes_parent_directory to cover this. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): drop alias pattern enforcement from _validate() Aliases are intentionally free-form to preserve community extension compatibility (e.g. 'speckit.verify' short aliases used by spec-kit-verify and other existing extensions). This aligns _validate() with the intent of upstream commit 4deb90f (fix: restore alias compatibility, #2110/#2125). Only type and None-normalization checks remain for aliases. Pattern enforcement continues for primary command names only. Updated tests to verify free-form aliases pass through unchanged with no warnings instead of being auto-corrected. Co-Authored-By: Claude Sonnet 4.6 * fix(extensions): guard against non-dict command entries in _validate() If provides.commands contains a non-mapping entry (e.g. an int or string), 'name' not in cmd raises TypeError instead of a user-facing ValidationError. Added isinstance(cmd, dict) check at the top of the loop. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: iamaeroplane Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 10 +- docs/installation.md | 10 ++ src/specify_cli/__init__.py | 21 +++- src/specify_cli/agents.py | 9 ++ src/specify_cli/extensions.py | 90 ++++++++++++++- tests/test_extensions.py | 205 +++++++++++++++++++++++++++++++++- tests/test_presets.py | 3 +- 7 files changed, 334 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a8a8d3a3ed..54e245d63b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Spec-Driven Development **flips the script** on traditional software development Choose your preferred installation method: +> **Important:** The only official, maintained packages for Spec Kit are published from this GitHub repository. Any packages with the same name on PyPI are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. Always install directly from GitHub as shown below. + #### Option 1: Persistent Installation (Recommended) Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): @@ -62,7 +64,13 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX uv tool install specify-cli --from git+https://github.com/github/spec-kit.git ``` -Then use the tool directly: +Then verify the correct version is installed: + +```bash +specify version +``` + +And use the tool directly: ```bash # Create new project diff --git a/docs/installation.md b/docs/installation.md index 5d560b6e33..ed253902af 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -10,6 +10,8 @@ ## Installation +> **Important:** The only official, maintained packages for Spec Kit come from the [github/spec-kit](https://github.com/github/spec-kit) GitHub repository. Any packages with the same name available on PyPI (e.g. `specify-cli` on pypi.org) are **not** affiliated with this project and are not maintained by the Spec Kit maintainers. For normal installs, use the GitHub-based commands shown below. For offline or air-gapped environments, locally built wheels created from this repository are also valid. + ### Initialize a New Project The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): @@ -69,6 +71,14 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init Optional[str]: + """Try to auto-correct a non-conforming command name to the required pattern. + + Handles the two legacy formats used by community extensions: + - 'speckit.command' → 'speckit.{ext_id}.command' + - '{ext_id}.command' → 'speckit.{ext_id}.command' + + The 'X.Y' form is only corrected when X matches ext_id to ensure the + result passes the install-time namespace check. Any other prefix is + uncorrectable and will produce a ValidationError at the call site. + + Returns the corrected name, or None if no safe correction is possible. + """ + parts = name.split('.') + if len(parts) == 2: + if parts[0] == 'speckit' or parts[0] == ext_id: + candidate = f"speckit.{ext_id}.{parts[1]}" + if EXTENSION_COMMAND_NAME_PATTERN.match(candidate): + return candidate + return None @property def id(self) -> str: diff --git a/tests/test_extensions.py b/tests/test_extensions.py index a6ddff8e1a..bec939702f 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -243,7 +243,7 @@ def test_invalid_version(self, temp_dir, valid_manifest_data): ExtensionManifest(manifest_path) def test_invalid_command_name(self, temp_dir, valid_manifest_data): - """Test manifest with invalid command name format.""" + """Test manifest with command name that cannot be auto-corrected raises ValidationError.""" import yaml valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name" @@ -255,6 +255,83 @@ def test_invalid_command_name(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="Invalid command name"): ExtensionManifest(manifest_path) + def test_command_name_autocorrect_speckit_prefix(self, temp_dir, valid_manifest_data): + """Test that 'speckit.command' is auto-corrected to 'speckit.{ext_id}.command'.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["name"] = "speckit.hello" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["name"] == "speckit.test-ext.hello" + assert len(manifest.warnings) == 1 + assert "speckit.hello" in manifest.warnings[0] + assert "speckit.test-ext.hello" in manifest.warnings[0] + + def test_command_name_autocorrect_matching_ext_id_prefix(self, temp_dir, valid_manifest_data): + """Test that '{ext_id}.command' is auto-corrected to 'speckit.{ext_id}.command'.""" + import yaml + + # Set ext_id to match the legacy namespace so correction is valid + valid_manifest_data["extension"]["id"] = "docguard" + valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["name"] == "speckit.docguard.guard" + assert len(manifest.warnings) == 1 + assert "docguard.guard" in manifest.warnings[0] + assert "speckit.docguard.guard" in manifest.warnings[0] + + def test_command_name_mismatched_namespace_not_corrected(self, temp_dir, valid_manifest_data): + """Test that 'X.command' is NOT corrected when X doesn't match ext_id.""" + import yaml + + # ext_id is "test-ext" but command uses a different namespace + valid_manifest_data["provides"]["commands"][0]["name"] = "docguard.guard" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid command name"): + ExtensionManifest(manifest_path) + + def test_alias_free_form_accepted(self, temp_dir, valid_manifest_data): + """Aliases are free-form — a 'speckit.command' alias must be accepted unchanged.""" + import yaml + + valid_manifest_data["provides"]["commands"][0]["aliases"] = ["speckit.hello"] + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.commands[0]["aliases"] == ["speckit.hello"] + assert manifest.warnings == [] + + def test_valid_command_name_has_no_warnings(self, temp_dir, valid_manifest_data): + """Test that a correctly-named command produces no warnings.""" + import yaml + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + manifest = ExtensionManifest(manifest_path) + + assert manifest.warnings == [] + def test_no_commands_no_hooks(self, temp_dir, valid_manifest_data): """Test manifest with no commands and no hooks provided.""" import yaml @@ -317,6 +394,19 @@ def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data): with pytest.raises(ValidationError, match="Invalid hooks"): ExtensionManifest(manifest_path) + def test_non_dict_hook_entry_raises_validation_error(self, temp_dir, valid_manifest_data): + """Non-mapping hook entries must raise ValidationError, not silently skip.""" + import yaml + + valid_manifest_data["hooks"]["after_tasks"] = "speckit.test-ext.hello" + + manifest_path = temp_dir / "extension.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_manifest_data, f) + + with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"): + ExtensionManifest(manifest_path) + def test_manifest_hash(self, extension_dir): """Test manifest hash calculation.""" manifest_path = extension_dir / "extension.yml" @@ -686,8 +776,8 @@ def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_ with pytest.raises(ValidationError, match="conflicts with core command namespace"): manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) - def test_install_accepts_short_alias(self, temp_dir, project_dir): - """Install should accept legacy short aliases for community extension compat.""" + def test_install_accepts_free_form_alias(self, temp_dir, project_dir): + """Aliases are free-form — a short 'speckit.shortcut' alias must be preserved unchanged.""" import yaml ext_dir = temp_dir / "alias-shortcut" @@ -718,8 +808,10 @@ def test_install_accepts_short_alias(self, temp_dir, project_dir): (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") manager = ExtensionManager(project_dir) - # Should not raise — short aliases are allowed - manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + + assert manifest.commands[0]["aliases"] == ["speckit.shortcut"] + assert manifest.warnings == [] def test_install_rejects_namespace_squatting(self, temp_dir, project_dir): """Install should reject commands and aliases outside the extension namespace.""" @@ -1619,6 +1711,54 @@ def test_non_copilot_agent_no_companion_file(self, extension_dir, project_dir): prompts_dir = project_dir / ".github" / "prompts" assert not prompts_dir.exists() + def test_unregister_skill_removes_parent_directory(self, project_dir, temp_dir): + """Unregistering a SKILL.md command should remove the empty parent subdirectory.""" + import yaml + + ext_dir = temp_dir / "cleanup-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "cleanup-ext", + "name": "Cleanup Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.cleanup-ext.run", + "file": "commands/run.md", + "description": "Run", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + (ext_dir / "commands" / "run.md").write_text("---\ndescription: Run\n---\n\nBody") + + skills_dir = project_dir / ".agents" / "skills" + skills_dir.mkdir(parents=True) + + registrar = CommandRegistrar() + from specify_cli.extensions import ExtensionManifest + manifest = ExtensionManifest(ext_dir / "extension.yml") + registered = registrar.register_commands_for_agent("codex", manifest, ext_dir, project_dir) + + skill_subdir = skills_dir / "speckit-cleanup-ext-run" + assert skill_subdir.exists(), "Skill subdirectory should exist after registration" + assert (skill_subdir / "SKILL.md").exists() + + registrar.unregister_commands({"codex": ["speckit.cleanup-ext.run"]}, project_dir) + + assert not (skill_subdir / "SKILL.md").exists(), "SKILL.md should be removed" + assert not skill_subdir.exists(), "Empty parent subdirectory should be removed" + # ===== Utility Function Tests ===== @@ -3853,3 +3993,58 @@ def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir): assert "Executing: `/`" in message assert "EXECUTE_COMMAND: " in message assert "EXECUTE_COMMAND_INVOCATION: /" in message + + +class TestExtensionRemoveCLI: + """CLI tests for `specify extension remove` confirmation prompt wording.""" + + def _install_ext(self, project_dir, ext_dir): + """Install extension and return the manager.""" + manager = ExtensionManager(project_dir) + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + return manager + + def test_remove_confirmation_singular_command(self, tmp_path, extension_dir): + """Confirmation prompt should say '1 command' (singular) when one command registered.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manager = self._install_ext(project_dir, extension_dir) + # Inject registered_commands with 1 entry so cmd_count == 1 + manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello"]}}) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False + ) + + assert "1 command" in result.output + assert "1 commands" not in result.output + + def test_remove_confirmation_plural_commands(self, tmp_path, extension_dir): + """Confirmation prompt should say '2 commands' (plural) when two commands registered.""" + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + + manager = self._install_ext(project_dir, extension_dir) + # Inject registered_commands with 2 entries so cmd_count == 2 + manager.registry.update("test-ext", {"registered_commands": {"claude": ["speckit.test-ext.hello", "speckit.test-ext.run"]}}) + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + result = runner.invoke( + app, ["extension", "remove", "test-ext"], input="n\n", catch_exceptions=False + ) + + assert "2 commands" in result.output diff --git a/tests/test_presets.py b/tests/test_presets.py index 95af7a900f..c7383a1f49 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1175,8 +1175,7 @@ def test_search_with_cached_data(self, project_dir, monkeypatch): """Test search with cached catalog data.""" from unittest.mock import patch - # Only use the default catalog to prevent fetching the community catalog from the network - monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", PresetCatalog.DEFAULT_CATALOG_URL) + monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False) catalog = PresetCatalog(project_dir) catalog.cache_dir.mkdir(parents=True, exist_ok=True) From 2f5417f0addba343b07c8f805b6b30159cab1dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8E=E5=AD=90=E5=90=8C=E8=AA=AC?= <541959443@qq.com> Date: Wed, 15 Apr 2026 21:26:46 +0800 Subject: [PATCH 049/184] Add agent-assign extension to community catalog (#2030) * Add agent-assign extension to community catalog * Fix author name to xuyang in catalog entry * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update updated_at date in catalog.community.json * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: xuyang Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 1 + extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 54e245d63b..031d663476 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ The following community-contributed extensions are available in [`catalog.commun | Extension | Purpose | Category | Effect | URL | |-----------|---------|----------|--------|-----| +| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) | | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | | Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index dd7f4f3a16..6589093444 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -36,6 +36,38 @@ "created_at": "2026-03-18T00:00:00Z", "updated_at": "2026-03-18T00:00:00Z" }, + "agent-assign": { + "name": "Agent Assign", + "id": "agent-assign", + "description": "Assign specialized Claude Code agents to spec-kit tasks for targeted execution", + "author": "xuyang", + "version": "1.0.0", + "download_url": "https://github.com/xymelon/spec-kit-agent-assign/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/xymelon/spec-kit-agent-assign", + "homepage": "https://github.com/xymelon/spec-kit-agent-assign", + "documentation": "https://github.com/xymelon/spec-kit-agent-assign/blob/main/README.md", + "changelog": "https://github.com/xymelon/spec-kit-agent-assign/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.3.0" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "agent", + "automation", + "implementation", + "multi-agent", + "task-routing" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-03-31T00:00:00Z", + "updated_at": "2026-03-31T00:00:00Z" + }, "architect-preview": { "name": "Architect Impact Previewer", "id": "architect-preview", From b78a3cdd88f3e43061f7e26800fe81dc5ac833bf Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:57:06 -0500 Subject: [PATCH 050/184] docs: merge TESTING.md into CONTRIBUTING.md, remove TESTING.md (#2228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: merge TESTING.md into CONTRIBUTING.md, remove TESTING.md Merge relevant testing content (automated checks, manual testing process, reporting template, test-selection prompt) into CONTRIBUTING.md. Remove obsolete content referencing deleted zip bundles and the non-existent test_core_pack_scaffold.py file. Update DEVELOPMENT.md to remove the TESTING.md entry. Closes #2226 * docs: address review — narrow automated checks intro, use cross-platform temp path --- CONTRIBUTING.md | 104 +++++++++++++++++++++++++++++-------- DEVELOPMENT.md | 3 +- TESTING.md | 133 ------------------------------------------------ 3 files changed, 85 insertions(+), 155 deletions(-) delete mode 100644 TESTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9044ef5ff9..4ce19657ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,8 +44,7 @@ On [GitHub Codespaces](https://github.com/features/codespaces) it's even simpler 1. Push to your fork and submit a pull request 1. Wait for your pull request to be reviewed and merged. -For the detailed test workflow, command-selection prompt, and PR reporting template, see [`TESTING.md`](./TESTING.md). -Activate the project virtual environment (see the Setup block in [`TESTING.md`](./TESTING.md)), then install the CLI from your working tree (`uv pip install -e .` after `uv sync --extra test`) or otherwise ensure the shell uses the local `specify` binary before running the manual slash-command tests described below. +Activate the project virtual environment (see [Testing setup](#testing-setup) below), then install the CLI from your working tree (`uv pip install -e .` after `uv sync --extra test`) or otherwise ensure the shell uses the local `specify` binary before running the manual slash-command tests described below. Here are a few things you can do that will increase the likelihood of your pull request being accepted: @@ -69,34 +68,99 @@ When working on spec-kit: For the smoothest review experience, validate changes in this order: -1. **Run focused automated checks first** — use the quick verification commands in [`TESTING.md`](./TESTING.md) to catch packaging, scaffolding, and configuration regressions early. -2. **Run manual workflow tests second** — if your change affects slash commands or the developer workflow, follow [`TESTING.md`](./TESTING.md) to choose the right commands, run them in an agent, and capture results for your PR. -3. **Use local release packages when debugging packaged output** — if you need to inspect the exact files CI-style packaging produces, generate local release packages as described below. +1. **Run focused automated checks first** — use the quick verification commands [below](#automated-checks) to catch scaffolding and configuration regressions early. +2. **Run manual workflow tests second** — if your change affects slash commands or the developer workflow, follow the [manual testing](#manual-testing) section to choose the right commands, run them in an agent, and capture results for your PR. -### Testing template and command changes locally +### Automated checks -Running `uv run specify init` pulls released packages, which won’t include your local changes. -To test your templates, commands, and other changes locally, follow these steps: +#### Agent configuration and wiring consistency -1. **Create release packages** +```bash +uv run python -m pytest tests/test_agent_config_consistency.py -q +``` - Run the following command to generate the local packages: +Run this when you change agent metadata, context update scripts, or integration wiring. - ```bash - ./.github/workflows/scripts/create-release-packages.sh v1.0.0 - ``` +### Manual testing -2. **Copy the relevant package to your test project** +#### Testing setup - ```bash - cp -r .genreleases/sdd-copilot-package-sh/. / - ``` +```bash +# Install the project and test dependencies from your local branch +cd +uv sync --extra test +source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1 +uv pip install -e . +# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing. -3. **Open and test the agent** +# Initialize a test project using your local changes +uv run specify init /speckit-test --ai --offline +cd /speckit-test - Navigate to your test project folder and open the agent to verify your implementation. +# Open in your agent +``` -If you only need to validate generated file structure and content before doing manual agent testing, start with the focused automated checks in [`TESTING.md`](./TESTING.md). Keep this section for the cases where you need to inspect the exact packaged output locally. +#### Manual testing process + +Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR. + +1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing. +2. **Set up a test project** — scaffold from your local branch (see [Testing setup](#testing-setup)). +3. **Run each affected command** — invoke it in your agent, verify it completes successfully, and confirm it produces the expected output (files created, scripts executed, artifacts populated). +4. **Run prerequisites first** — commands that depend on earlier commands (e.g., `/speckit.tasks` requires `/speckit.plan` which requires `/speckit.specify`) must be run in order. +5. **Report results** — paste the [reporting template](#reporting-results) into your PR with pass/fail for each command tested. + +#### Reporting results + +Paste this into your PR: + +~~~markdown +## Manual test results + +**Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh] + +| Command tested | Notes | +|----------------|-------| +| `/speckit.command` | | +~~~ + +#### Determining which tests to run + +Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR. + +~~~text +Read CONTRIBUTING.md, then run `git diff --name-only main` to get my changed files. +For each changed file, determine which slash commands it affects by reading +the command templates in templates/commands/ to understand what each command +invokes. Use these mapping rules: + +- templates/commands/X.md → the command it defines +- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected +- templates/Z-template.md → every command that consumes that template during execution +- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify +- extensions/X/commands/* → the extension command it defines +- extensions/X/scripts/* → every extension command that invokes that script +- extensions/X/extension.yml or config-template.yml → every command in that extension. Also check if the manifest defines hooks (look for `hooks:` entries like `before_specify`, `after_implement`, etc.) — if so, the core commands those hooks attach to are also affected +- presets/*/* → test preset scaffolding via `specify init` with the preset +- pyproject.toml → packaging/bundling; test `specify init` and verify bundled assets + +Include prerequisite tests (e.g., T5 requires T3 requires T1). + +Output in this format: + +### Test selection reasoning + +| Changed file | Affects | Test | Why | +|---|---|---|---| +| (path) | (command) | T# | (reason) | + +### Required tests + +Number each test sequentially (T1, T2, ...). List prerequisite tests first. + +- T1: /speckit.command — (reason) +- T2: /speckit.command — (reason) +~~~ ## AI contributions in Spec Kit diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index dc35bc6fe0..946e071e31 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -11,8 +11,7 @@ Spec Kit is a toolkit for spec-driven development. At its core, it is a coordina | [spec-driven.md](spec-driven.md) | End-to-end explanation of the Spec-Driven Development workflow supported by Spec Kit. | | [RELEASE-PROCESS.md](.github/workflows/RELEASE-PROCESS.md) | Release workflow, versioning rules, and changelog generation process. | | [docs/index.md](docs/index.md) | Entry point to the `docs/` documentation set. | -| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, and required development practices. | -| [TESTING.md](TESTING.md) | Validation strategy and testing procedures. | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, testing, and required development practices. | **Main repository components:** diff --git a/TESTING.md b/TESTING.md deleted file mode 100644 index 1fa6b1c881..0000000000 --- a/TESTING.md +++ /dev/null @@ -1,133 +0,0 @@ -# Testing Guide - -This document is the detailed testing companion to [`CONTRIBUTING.md`](./CONTRIBUTING.md). - -Use it for three things: - -1. running quick automated checks before manual testing, -2. manually testing affected slash commands through an AI agent, and -3. capturing the results in a PR-friendly format. - -Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR. - -## Recommended order - -1. **Sync your environment** — install the project and test dependencies. -2. **Run focused automated checks** — especially for packaging, scaffolding, agent config, and generated-file changes. -3. **Run manual agent tests** — for any affected slash commands. -4. **Paste results into your PR** — include both command-selection reasoning and manual test results. - -## Quick automated checks - -Run these before manual testing when your change affects packaging, scaffolding, templates, release artifacts, or agent wiring. - -### Environment setup - -```bash -cd -uv sync --extra test -source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1 -``` - -### Generated package structure and content - -```bash -uv run python -m pytest tests/test_core_pack_scaffold.py -q -``` - -This validates the generated files that CI-style packaging depends on, including directory layout, file names, frontmatter/TOML validity, placeholder replacement, `.specify/` path rewrites, and parity with `create-release-packages.sh`. - -### Agent configuration and release wiring consistency - -```bash -uv run python -m pytest tests/test_agent_config_consistency.py -q -``` - -Run this when you change agent metadata, release scripts, context update scripts, or artifact naming. - -### Optional single-agent packaging spot check - -```bash -AGENTS=copilot SCRIPTS=sh ./.github/workflows/scripts/create-release-packages.sh v1.0.0 -``` - -Inspect `.genreleases/sdd-copilot-package-sh/` and the matching ZIP in `.genreleases/` when you want to review the exact packaged output for one agent/script combination. - -## Manual testing process - -1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing. -2. **Set up a test project** — scaffold from your local branch (see [Setup](#setup)). -3. **Run each affected command** — invoke it in your agent, verify it completes successfully, and confirm it produces the expected output (files created, scripts executed, artifacts populated). -4. **Run prerequisites first** — commands that depend on earlier commands (e.g., `/speckit.tasks` requires `/speckit.plan` which requires `/speckit.specify`) must be run in order. -5. **Report results** — paste the [reporting template](#reporting-results) into your PR with pass/fail for each command tested. - -## Setup - -```bash -# Install the project and test dependencies from your local branch -cd -uv sync --extra test -source .venv/bin/activate # On Windows (CMD): .venv\Scripts\activate | (PowerShell): .venv\Scripts\Activate.ps1 -uv pip install -e . -# Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing. - -# Initialize a test project using your local changes -uv run specify init /tmp/speckit-test --ai --offline -cd /tmp/speckit-test - -# Open in your agent -``` - -If you are testing the packaged output rather than the live source tree, create a local release package first as described in [`CONTRIBUTING.md`](./CONTRIBUTING.md). - -## Reporting results - -Paste this into your PR: - -~~~markdown -## Manual test results - -**Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh] - -| Command tested | Notes | -|----------------|-------| -| `/speckit.command` | | -~~~ - -## Determining which tests to run - -Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR. - -~~~text -Read TESTING.md, then run `git diff --name-only main` to get my changed files. -For each changed file, determine which slash commands it affects by reading -the command templates in templates/commands/ to understand what each command -invokes. Use these mapping rules: - -- templates/commands/X.md → the command it defines -- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected -- templates/Z-template.md → every command that consumes that template during execution -- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify -- extensions/X/commands/* → the extension command it defines -- extensions/X/scripts/* → every extension command that invokes that script -- extensions/X/extension.yml or config-template.yml → every command in that extension. Also check if the manifest defines hooks (look for `hooks:` entries like `before_specify`, `after_implement`, etc.) — if so, the core commands those hooks attach to are also affected -- presets/*/* → test preset scaffolding via `specify init` with the preset -- pyproject.toml → packaging/bundling; test `specify init` and verify bundled assets - -Include prerequisite tests (e.g., T5 requires T3 requires T1). - -Output in this format: - -### Test selection reasoning - -| Changed file | Affects | Test | Why | -|---|---|---|---| -| (path) | (command) | T# | (reason) | - -### Required tests - -Number each test sequentially (T1, T2, ...). List prerequisite tests first. - -- T1: /speckit.command — (reason) -- T2: /speckit.command — (reason) -~~~ From 8fc2bd3489c86ee38110baa1198ae27850f74c96 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:35:05 -0500 Subject: [PATCH 051/184] fix: allow Claude to chain skills for hook execution (#2227) * fix: allow Claude to chain skills for hook execution (#2178) - Set disable-model-invocation to false so Claude can invoke extension skills (e.g. speckit-git-feature) from within workflow skills - Inject dot-to-hyphen normalization note into Claude SKILL.md hook sections so the model maps extension.yml command names to skill names - Replace Unicode checkmark with ASCII [OK] in auto-commit scripts to fix PowerShell encoding errors on Windows - Move Claude-specific frontmatter injection to ClaudeIntegration via post_process_skill_content() hook on SkillsIntegration, wired through presets and extensions managers - Add positive and negative tests for all changes Fixes #2178 * refactor: address PR review feedback - Preserve line-ending style (CRLF/LF) in _inject_hook_command_note instead of always inserting \n, matching the convention used by other injection helpers in the same module. - Extract duplicated _post_process_skill() from extensions.py and presets.py into a shared post_process_skill() function in agents.py. Both modules now import and call the shared helper. * fix: match full hook instruction line in regex The regex in _inject_hook_command_note only matched lines ending immediately after 'output the following', but the actual template lines continue with 'based on its `optional` flag:'. Use [^\r\n]* to capture the rest of the line before the EOL. * refactor: use integration object directly for post_process_skill_content Instead of a free function in agents.py that re-resolves the integration by key, callers in extensions.py and presets.py now resolve the integration once via get_integration() and call integration.post_process_skill_content() directly. The base identity method lives on SkillsIntegration. --- extensions/git/scripts/bash/auto-commit.sh | 2 +- .../git/scripts/powershell/auto-commit.ps1 | 2 +- src/specify_cli/agents.py | 5 - src/specify_cli/extensions.py | 6 + src/specify_cli/integrations/base.py | 10 ++ .../integrations/claude/__init__.py | 55 +++++++- src/specify_cli/presets.py | 16 +++ tests/extensions/git/test_git_extension.py | 56 +++++++++ tests/integrations/test_integration_claude.py | 118 +++++++++++++++++- tests/test_extension_skills.py | 2 +- tests/test_presets.py | 4 +- 11 files changed, 257 insertions(+), 19 deletions(-) diff --git a/extensions/git/scripts/bash/auto-commit.sh b/extensions/git/scripts/bash/auto-commit.sh index 49c32fe634..f0b423187b 100755 --- a/extensions/git/scripts/bash/auto-commit.sh +++ b/extensions/git/scripts/bash/auto-commit.sh @@ -137,4 +137,4 @@ fi _git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; } _git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; } -echo "✓ Changes committed ${_phase} ${_command_name}" >&2 +echo "[OK] Changes committed ${_phase} ${_command_name}" >&2 diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 index e9777ff9be..2229ed2b8d 100644 --- a/extensions/git/scripts/powershell/auto-commit.ps1 +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -146,4 +146,4 @@ try { exit 1 } -Write-Host "✓ Changes committed $phase $commandName" +Write-Host "[OK] Changes committed $phase $commandName" diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index c177e2a27c..32fc6cdbf0 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -317,11 +317,6 @@ def build_skill_frontmatter( "source": source, }, } - if agent_name == "claude": - # Claude skills should be user-invocable (accessible via /command) - # and only run when explicitly invoked (not auto-triggered by the model). - skill_frontmatter["user-invocable"] = True - skill_frontmatter["disable-model-invocation"] = True return skill_frontmatter @staticmethod diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 852699c39b..d5543cd0b4 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -850,6 +850,7 @@ def _register_extension_skills( from . import load_init_options from .agents import CommandRegistrar + from .integrations import get_integration import yaml written: List[str] = [] @@ -860,6 +861,7 @@ def _register_extension_skills( if not isinstance(selected_ai, str) or not selected_ai: return [] registrar = CommandRegistrar() + integration = get_integration(selected_ai) for cmd_info in manifest.commands: cmd_name = cmd_info["name"] @@ -939,6 +941,10 @@ def _register_extension_skills( f"# {title_name} Skill\n\n" f"{body}\n" ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) skill_file.write_text(skill_content, encoding="utf-8") written.append(skill_name) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 26501e623f..2c01e25b0e 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -1102,6 +1102,16 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: invocation = f"{invocation} {args}" return invocation + def post_process_skill_content(self, content: str) -> str: + """Post-process a SKILL.md file's content after generation. + + Called by external skill generators (presets, extensions) to let + the integration inject agent-specific frontmatter or body + transformations. The default implementation returns *content* + unchanged. Subclasses may override — see ``ClaudeIntegration``. + """ + return content + def setup( self, project_root: Path, diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 31972c4b0e..3e39db717e 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -5,11 +5,21 @@ from pathlib import Path from typing import Any +import re + import yaml from ..base import SkillsIntegration from ..manifest import IntegrationManifest +# Note injected into hook sections so Claude maps dot-notation command +# names (from extensions.yml) to the hyphenated skill names 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" +) + # Mapping of command template stem → argument-hint text shown inline # when a user invokes the slash command in Claude Code. ARGUMENT_HINTS: dict[str, str] = { @@ -148,6 +158,43 @@ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str out.append(line) return "".join(out) + @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, + ) + + def post_process_skill_content(self, content: str) -> str: + """Inject Claude-specific frontmatter flags and hook notes.""" + updated = self._inject_frontmatter_flag(content, "user-invocable") + updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false") + updated = self._inject_hook_command_note(updated) + return updated + def setup( self, project_root: Path, @@ -155,7 +202,7 @@ def setup( parsed_options: dict[str, Any] | None = None, **opts: Any, ) -> list[Path]: - """Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint.""" + """Install Claude skills, then inject Claude-specific flags and argument-hints.""" created = super().setup(project_root, manifest, parsed_options, **opts) # Post-process generated skill files @@ -173,11 +220,7 @@ def setup( content_bytes = path.read_bytes() content = content_bytes.decode("utf-8") - # Inject user-invocable: true (Claude skills are accessible via /command) - updated = self._inject_frontmatter_flag(content, "user-invocable") - - # Inject disable-model-invocation: true (Claude skills run only when invoked) - updated = self._inject_frontmatter_flag(updated, "disable-model-invocation") + updated = self.post_process_skill_content(content) # Inject argument-hint if available for this skill skill_dir_name = path.parent.name # e.g. "speckit-plan" diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 3a0f469a77..d5513c8323 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -707,6 +707,7 @@ def _register_skills( from . import SKILL_DESCRIPTIONS, load_init_options from .agents import CommandRegistrar + from .integrations import get_integration init_opts = load_init_options(self.project_root) if not isinstance(init_opts, dict): @@ -716,6 +717,7 @@ def _register_skills( return [] ai_skills_enabled = bool(init_opts.get("ai_skills")) registrar = CommandRegistrar() + integration = get_integration(selected_ai) agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) # Native skill agents (e.g. codex/kimi/agy/trae) materialize brand-new # preset skills in _register_commands() because their detected agent @@ -789,6 +791,10 @@ def _register_skills( f"# Speckit {skill_title} Skill\n\n" f"{body}\n" ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) skill_file = skill_subdir / "SKILL.md" skill_file.write_text(skill_content, encoding="utf-8") @@ -816,6 +822,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: from . import SKILL_DESCRIPTIONS, load_init_options from .agents import CommandRegistrar + from .integrations import get_integration # Locate core command templates from the project's installed templates core_templates_dir = self.project_root / ".specify" / "templates" / "commands" @@ -824,6 +831,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: init_opts = {} selected_ai = init_opts.get("ai") registrar = CommandRegistrar() + integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None extension_restore_index = self._build_extension_skill_restore_index() for skill_name in skill_names: @@ -877,6 +885,10 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: f"# Speckit {skill_title} Skill\n\n" f"{body}\n" ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) skill_file.write_text(skill_content, encoding="utf-8") continue @@ -906,6 +918,10 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None: f"# {title_name} Skill\n\n" f"{body}\n" ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content( + skill_content + ) skill_file.write_text(skill_content, encoding="utf-8") else: # No core or extension template — remove the skill entirely diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 50ab9c7b6b..a04acba10b 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -491,6 +491,34 @@ def test_requires_event_name_argument(self, tmp_path: Path): result = _run_bash("auto-commit.sh", project) assert result.returncode != 0 + def test_success_message_uses_ok_prefix(self, tmp_path: Path): + """auto-commit.sh success message uses [OK] (not Unicode).""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_bash("auto-commit.sh", project, "after_specify") + assert result.returncode == 0 + assert "[OK] Changes committed" in result.stderr + + def test_success_message_no_unicode_checkmark(self, tmp_path: Path): + """auto-commit.sh must not use Unicode checkmark in output.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_bash("auto-commit.sh", project, "after_plan") + assert result.returncode == 0 + assert "\u2713" not in result.stderr, "Must not use Unicode checkmark" + @pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") class TestAutoCommitPowerShell: @@ -523,6 +551,34 @@ def test_enabled_per_command(self, tmp_path: Path): ) assert "ps commit" in log.stdout + def test_success_message_uses_ok_prefix(self, tmp_path: Path): + """auto-commit.ps1 success message uses [OK] (not Unicode).""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + assert "[OK] Changes committed" in result.stdout + + def test_success_message_no_unicode_checkmark(self, tmp_path: Path): + """auto-commit.ps1 must not use Unicode checkmark in output.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + )) + (project / "new-file.txt").write_text("content") + result = _run_pwsh("auto-commit.ps1", project, "after_plan") + assert result.returncode == 0 + assert "\u2713" not in result.stdout, "Must not use Unicode checkmark" + # ── git-common.sh Tests ────────────────────────────────────────────────────── diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 7fd69df176..d3b01097fc 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -59,7 +59,7 @@ def test_setup_creates_skill_files(self, tmp_path): parsed = yaml.safe_load(parts[1]) assert parsed["name"] == "speckit-plan" assert parsed["user-invocable"] is True - assert parsed["disable-model-invocation"] is True + assert parsed["disable-model-invocation"] is False assert parsed["metadata"]["source"] == "templates/commands/plan.md" def test_setup_installs_update_context_scripts(self, tmp_path): @@ -179,7 +179,7 @@ def test_interactive_claude_selection_uses_integration_path(self, tmp_path): assert skill_file.exists() skill_content = skill_file.read_text(encoding="utf-8") assert "user-invocable: true" in skill_content - assert "disable-model-invocation: true" in skill_content + assert "disable-model-invocation: false" in skill_content init_options = json.loads( (project / ".specify" / "init-options.json").read_text(encoding="utf-8") @@ -280,7 +280,7 @@ def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path): assert "preset:claude-skill-command" in content assert "name: speckit-research" in content assert "user-invocable: true" in content - assert "disable-model-invocation: true" in content + assert "disable-model-invocation: false" in content metadata = manager.registry.get("claude-skill-command") assert "speckit-research" in metadata.get("registered_skills", []) @@ -400,3 +400,115 @@ def test_inject_argument_hint_skips_if_already_present(self): lines = result.splitlines() hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:")) assert hint_count == 1 + + +class TestClaudeDisableModelInvocation: + """Verify disable-model-invocation is false for Claude skills.""" + + def test_setup_sets_disable_model_invocation_false(self, tmp_path): + """Generated SKILL.md files must have disable-model-invocation: false.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + assert parsed["disable-model-invocation"] is False, ( + f"{f.parent.name}: expected disable-model-invocation: false" + ) + + def test_disable_model_invocation_not_true(self, tmp_path): + """No Claude skill should have disable-model-invocation: true.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + for f in created: + if f.name != "SKILL.md": + continue + content = f.read_text(encoding="utf-8") + assert "disable-model-invocation: true" not in content, ( + f"{f.parent.name}: must not have disable-model-invocation: true" + ) + + def test_non_claude_agents_lack_disable_model_invocation(self, tmp_path): + """Non-Claude skill agents should not get disable-model-invocation.""" + from specify_cli.agents import CommandRegistrar + + fm = CommandRegistrar.build_skill_frontmatter( + "codex", "speckit-plan", "desc", "templates/commands/plan.md" + ) + assert "disable-model-invocation" not in fm + assert "user-invocable" not in fm + + def test_non_claude_post_process_is_identity(self, tmp_path): + """Non-Claude integrations should not modify skill content.""" + codex = get_integration("codex") + if codex is None: + return # codex not registered in this build + content = "---\nname: test\n---\nBody" + assert codex.post_process_skill_content(content) == content + + +class TestClaudeHookCommandNote: + """Verify dot-to-hyphen normalization note is injected in hook sections.""" + + def test_hook_note_injected_in_skills_with_hooks(self, tmp_path): + """Skills that have hook sections should get the normalization note.""" + i = get_integration("claude") + m = IntegrationManifest("claude", tmp_path) + created = i.setup(tmp_path, m, script_type="sh") + specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md" + assert specify_skill.exists() + content = specify_skill.read_text(encoding="utf-8") + # specify.md has hook sections + assert "replace dots" in content, ( + "speckit-specify should have dot-to-hyphen hook note" + ) + + def test_hook_note_not_in_skills_without_hooks(self, tmp_path): + """Skills without hook sections should not get the note.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n" + result = ClaudeIntegration._inject_hook_command_note(content) + assert "replace dots" not in result + + def test_hook_note_idempotent(self, tmp_path): + """Injecting the note twice should not duplicate it.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\nname: test\n---\n\n" + "- For each executable hook, output the following based on its flag:\n" + ) + once = ClaudeIntegration._inject_hook_command_note(content) + twice = ClaudeIntegration._inject_hook_command_note(once) + assert once == twice, "Hook note injection should be idempotent" + + def test_hook_note_preserves_indentation(self, tmp_path): + """The injected note should match the indentation of the target line.""" + from specify_cli.integrations.claude import ClaudeIntegration + + content = ( + "---\nname: test\n---\n\n" + " - For each executable hook, output the following\n" + ) + result = ClaudeIntegration._inject_hook_command_note(content) + lines = result.splitlines() + note_line = [l for l in lines if "replace dots" in l][0] + assert note_line.startswith(" "), "Note should preserve indentation" + + def test_post_process_injects_all_claude_flags(self): + """post_process_skill_content should inject all Claude-specific fields.""" + i = get_integration("claude") + content = ( + "---\nname: test\ndescription: test\n---\n\n" + "- For each executable hook, output the following\n" + ) + result = i.post_process_skill_content(content) + assert "user-invocable: true" in result + assert "disable-model-invocation: false" in result + assert "replace dots" in result diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index 8a9f19e74e..c9d13382ab 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -269,7 +269,7 @@ def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir): assert isinstance(parsed, dict) assert parsed["name"] == "speckit-test-ext-hello" assert "description" in parsed - assert parsed["disable-model-invocation"] is True + assert parsed["disable-model-invocation"] is False def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir): """No skills should be created when ai_skills is false.""" diff --git a/tests/test_presets.py b/tests/test_presets.py index c7383a1f49..b883d554b0 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1975,7 +1975,7 @@ def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): assert skill_file.exists() content = skill_file.read_text() assert "preset:self-test" in content, "Skill should reference preset source" - assert "disable-model-invocation: true" in content + assert "disable-model-invocation: false" in content # Verify it was recorded in registry metadata = manager.registry.get("self-test") @@ -2057,7 +2057,7 @@ def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): content = skill_file.read_text() assert "preset:self-test" not in content, "Preset content should be gone" assert "templates/commands/specify.md" in content, "Should reference core template" - assert "disable-model-invocation: true" in content + assert "disable-model-invocation: false" in content def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir): """Core restore should resolve {SCRIPT}/{ARGS} placeholders like other skill paths.""" From 27b4fd2e328efb4b10d1c8936738cabcd958be83 Mon Sep 17 00:00:00 2001 From: Ayesha Aziz <163914368+ayesha-aziz123@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:16:21 +0500 Subject: [PATCH 052/184] docs: remove deprecated --skip-tls references from local-development guide (#2231) * docs: remove deprecated --skip-tls references from local-development guide * docs: refine wording and fix formatting for deprecated --skip-tls * docs: polish TLS guidance wording --- docs/local-development.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/local-development.md b/docs/local-development.md index 7fac06adf4..a23ea1d88f 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -128,16 +128,14 @@ python -m src.specify_cli init --here --ai claude --ignore-agent-tools --script Or copy only the modified CLI portion if you want a lighter sandbox. -## 9. Debug Network / TLS Skips +## 9. Debug Network / TLS Issues -If you need to bypass TLS validation while experimenting: - -```bash -specify check --skip-tls -specify init demo --skip-tls --ai gemini --ignore-agent-tools --script ps -``` - -(Use only for local experimentation.) +> **Deprecated:** The `--skip-tls` flag is a no-op and has no effect. +> It was previously used to bypass TLS validation during local testing. +> If you encounter TLS errors (e.g., on a corporate network), configure your +> environment's certificate store or proxy instead. +> +> For example, set `SSL_CERT_FILE` or configure `HTTPS_PROXY` / `HTTP_PROXY`. ## 10. Rapid Edit Loop Summary @@ -166,7 +164,7 @@ rm -rf .venv dist build *.egg-info | Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` | | Git step skipped | You passed `--no-git` or Git not installed | | Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly | -| TLS errors on corporate network | Try `--skip-tls` (not for production) | +| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. | ## 13. Next Steps From 9988a46d96f7b3fe5e006327fb70d4de74eed217 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:37:27 -0500 Subject: [PATCH 053/184] ci: add windows-latest to test matrix (#2233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: add windows-latest to test matrix Add windows-latest to the pytest job OS matrix so tests run on both Ubuntu and Windows for all Python versions. Closes #2232 * test: skip bash-specific tests on Windows Add sys.platform skip markers to all test classes and methods that execute bash scripts via subprocess, so they are skipped on Windows where bash is not available. Mixed classes with both bash and pwsh tests have markers on individual bash methods only. * test: fix 3 Windows-specific test failures - test_manifest: use platform-appropriate absolute path (C:\ on Windows vs /tmp on POSIX) since /tmp is not absolute on Windows - test_extensions: add agent_scripts.ps entry and platform-conditional assertions for codex skill fallback variant test - test_timestamp_branches: use json.dumps() instead of f-string to properly escape Windows backslash paths in feature.json * test: extract requires_bash marker and fix PS test skip Address PR review feedback: - Define a reusable requires_bash marker in conftest.py and use it across all 3 test files instead of repeating the skipif inline - Move test_powershell_scanner_uses_long_tryparse_for_large_prefixes into its own TestSequentialBranchPowerShell class so it is not incorrectly skipped on Windows by the class-level bash marker * test: use runtime bash check instead of platform check Replace sys.platform == 'win32' with an actual bash invocation test to handle environments where bash exists but is non-functional (e.g., WSL stub on Windows without an installed distro). * test: reject WSL bash, accept only MSYS/MINGW on Windows On Windows, verify uname -s reports MSYS, MINGW, or CYGWIN so the WSL launcher (System32\bash.exe) is rejected — it cannot handle native Windows paths used by test fixtures. Add SPECKIT_TEST_BASH=1 env var escape hatch to force-enable bash tests in non-standard setups. * ci: add comment explaining Windows bash test behavior * test: early-reject WSL launcher, fix remaining f-string JSON - Check resolved bash path for System32 before spawning any subprocess to avoid WSL init prompts and timeout during test collection - Convert remaining feature_json f-string writes to json.dumps() so paths with backslashes produce valid JSON on Windows * test: use bare 'bash' for detection to match test invocation On Windows, subprocess.run(['bash', ...]) uses CreateProcess which searches System32 before PATH — finding WSL bash even when shutil.which('bash') returns Git-for-Windows. Probe with bare 'bash' (same as test helpers) so the detection matches actual test behavior. --- .github/workflows/test.yml | 7 ++- tests/conftest.py | 58 ++++++++++++++++++++++ tests/extensions/git/test_git_extension.py | 6 +++ tests/integrations/test_manifest.py | 4 +- tests/test_cursor_frontmatter.py | 3 ++ tests/test_extensions.py | 10 +++- tests/test_timestamp_branches.py | 25 ++++++++-- 7 files changed, 106 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18b039f02b..44b0269887 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,9 +27,10 @@ jobs: run: uvx ruff check src/ pytest: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: + os: [ubuntu-latest, windows-latest] python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout @@ -46,5 +47,9 @@ jobs: - name: Install dependencies run: uv sync --extra test + # On windows-latest, bash tests auto-skip unless Git-for-Windows + # bash (MSYS2/MINGW) is detected. The WSL launcher is rejected + # because it cannot handle native Windows paths in test fixtures. + # See tests/conftest.py::_has_working_bash() for details. - name: Run tests run: uv run pytest diff --git a/tests/conftest.py b/tests/conftest.py index 4387c9ac8f..9e8ffaae59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,68 @@ """Shared test helpers for the Spec Kit test suite.""" +import os import re +import shutil +import subprocess +import sys + +import pytest _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +def _has_working_bash() -> bool: + """Check whether a functional native bash is available. + + On Windows, ``subprocess.run(["bash", ...])`` uses CreateProcess, + which searches System32 *before* PATH — so it may find the WSL + launcher even when Git-for-Windows bash appears first in PATH via + ``shutil.which``. We therefore probe with bare ``"bash"`` (the + same way test helpers invoke it) to get an accurate result. + + On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted. + The WSL launcher is rejected because it runs in a separate Linux + filesystem and cannot handle native Windows paths used by the + test fixtures. + + Set SPECKIT_TEST_BASH=1 to force-enable bash tests regardless. + """ + if os.environ.get("SPECKIT_TEST_BASH") == "1": + return True + if shutil.which("bash") is None: + return False + # Probe with bare "bash" — same as the test helpers — so that + # Windows CreateProcess resolution order is respected. + try: + r = subprocess.run( + ["bash", "-c", "echo ok"], + capture_output=True, text=True, timeout=5, + ) + if r.returncode != 0 or "ok" not in r.stdout: + return False + except (OSError, subprocess.TimeoutExpired): + return False + # On Windows, verify we have MSYS/MINGW bash (Git for Windows), + # not the WSL launcher which can't handle native paths. + if sys.platform == "win32": + try: + u = subprocess.run( + ["bash", "-c", "uname -s"], + capture_output=True, text=True, timeout=5, + ) + kernel = u.stdout.strip().upper() + if not any(k in kernel for k in ("MSYS", "MINGW", "CYGWIN")): + return False + except (OSError, subprocess.TimeoutExpired): + return False + return True + + +requires_bash = pytest.mark.skipif( + not _has_working_bash(), reason="working bash not available" +) + + def strip_ansi(text: str) -> str: """Remove ANSI escape codes from Rich-formatted CLI output.""" return _ANSI_ESCAPE_RE.sub("", text) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index a04acba10b..30694fc9d8 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -18,6 +18,8 @@ import pytest +from tests.conftest import requires_bash + PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent EXT_DIR = PROJECT_ROOT / "extensions" / "git" EXT_BASH = EXT_DIR / "scripts" / "bash" @@ -211,6 +213,7 @@ def test_bundled_extension_locator(self): # ── initialize-repo.sh Tests ───────────────────────────────────────────────── +@requires_bash class TestInitializeRepoBash: def test_initializes_git_repo(self, tmp_path: Path): """initialize-repo.sh creates a git repo with initial commit.""" @@ -269,6 +272,7 @@ def test_skips_if_already_git_repo(self, tmp_path: Path): # ── create-new-feature.sh Tests ────────────────────────────────────────────── +@requires_bash class TestCreateFeatureBash: def test_creates_branch_sequential(self, tmp_path: Path): """Extension create-new-feature.sh creates sequential branch.""" @@ -376,6 +380,7 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): # ── auto-commit.sh Tests ───────────────────────────────────────────────────── +@requires_bash class TestAutoCommitBash: def test_disabled_by_default(self, tmp_path: Path): """auto-commit.sh exits silently when config is all false.""" @@ -583,6 +588,7 @@ def test_success_message_no_unicode_checkmark(self, tmp_path: Path): # ── git-common.sh Tests ────────────────────────────────────────────────────── +@requires_bash class TestGitCommonBash: def test_has_git_true(self, tmp_path: Path): """has_git returns 0 in a git repo.""" diff --git a/tests/integrations/test_manifest.py b/tests/integrations/test_manifest.py index b5d5bc39f5..596397d4f7 100644 --- a/tests/integrations/test_manifest.py +++ b/tests/integrations/test_manifest.py @@ -2,6 +2,7 @@ import hashlib import json +import sys import pytest @@ -41,8 +42,9 @@ def test_record_file_rejects_parent_traversal(self, tmp_path): def test_record_file_rejects_absolute_path(self, tmp_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_file("/tmp/escape.txt", "bad") + m.record_file(abs_path, "bad") def test_record_existing_rejects_parent_traversal(self, tmp_path): escape = tmp_path.parent / "escape.txt" diff --git a/tests/test_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py index d9d0e34237..9f8c31ce10 100644 --- a/tests/test_cursor_frontmatter.py +++ b/tests/test_cursor_frontmatter.py @@ -12,6 +12,8 @@ import pytest +from tests.conftest import requires_bash + SCRIPT_PATH = os.path.join( os.path.dirname(__file__), os.pardir, @@ -73,6 +75,7 @@ def test_powershell_script_has_mdc_frontmatter_logic(self): @requires_git +@requires_bash class TestCursorFrontmatterIntegration: """Integration tests using a real git repo.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index bec939702f..460404d597 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -11,6 +11,7 @@ import pytest import json +import platform import tempfile import shutil import tomllib @@ -1452,6 +1453,7 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti ps: ../../scripts/powershell/setup-plan.ps1 -Json agent_scripts: sh: ../../scripts/bash/update-agent-context.sh __AGENT__ + ps: ../../scripts/powershell/update-agent-context.ps1 __AGENT__ --- Run {SCRIPT} @@ -1473,8 +1475,12 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti content = skill_file.read_text() assert "{SCRIPT}" not in content assert "{AGENT_SCRIPT}" not in content - assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh codex" in content + if platform.system().lower().startswith("win"): + assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content + assert ".specify/scripts/powershell/update-agent-context.ps1 codex" in content + else: + assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content + assert ".specify/scripts/bash/update-agent-context.sh codex" in content def test_codex_skill_registration_handles_non_dict_init_options( self, project_dir, temp_dir diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index b258fa98d1..39228d9455 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -13,6 +13,8 @@ import pytest +from tests.conftest import requires_bash + PROJECT_ROOT = Path(__file__).resolve().parent.parent CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1" @@ -149,6 +151,7 @@ def source_and_call(func_call: str, env: dict | None = None) -> subprocess.Compl # ── Timestamp Branch Tests ─────────────────────────────────────────────────── +@requires_bash class TestTimestampBranch: def test_timestamp_creates_branch(self, git_repo: Path): """Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix.""" @@ -194,6 +197,7 @@ def test_long_name_truncation(self, git_repo: Path): # ── Sequential Branch Tests ────────────────────────────────────────────────── +@requires_bash class TestSequentialBranch: def test_sequential_default_with_existing_specs(self, git_repo: Path): """Test 2: Sequential default with existing specs.""" @@ -232,6 +236,8 @@ def test_sequential_supports_four_digit_prefixes(self, git_repo: Path): branch = line.split(":", 1)[1].strip() assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}" + +class TestSequentialBranchPowerShell: def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): """PowerShell scanner should parse large prefixes without [int] casts.""" content = CREATE_FEATURE_PS.read_text(encoding="utf-8") @@ -242,6 +248,7 @@ def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): # ── check_feature_branch Tests ─────────────────────────────────────────────── +@requires_bash class TestCheckFeatureBranch: def test_accepts_timestamp_branch(self): """Test 6: check_feature_branch accepts timestamp branch.""" @@ -306,6 +313,7 @@ def test_rejects_malformed_timestamp_with_prefix(self): # ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── +@requires_bash class TestFindFeatureDirByPrefix: def test_timestamp_branch(self, tmp_path: Path): """Test 10: find_feature_dir_by_prefix with timestamp branch.""" @@ -356,6 +364,7 @@ def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path): class TestGetFeaturePathsSinglePrefix: + @requires_bash def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path): """get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup.""" (tmp_path / ".specify").mkdir() @@ -399,6 +408,7 @@ def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): # ── get_current_branch Tests ───────────────────────────────────────────────── +@requires_bash class TestGetCurrentBranch: def test_env_var(self): """Test 12: get_current_branch returns SPECIFY_FEATURE env var.""" @@ -409,6 +419,7 @@ def test_env_var(self): # ── No-git Tests ───────────────────────────────────────────────────────────── +@requires_bash class TestNoGitTimestamp: def test_no_git_timestamp(self, no_git_dir: Path): """Test 13: No-git repo + timestamp creates spec dir with warning.""" @@ -422,6 +433,7 @@ def test_no_git_timestamp(self, no_git_dir: Path): # ── E2E Flow Tests ─────────────────────────────────────────────────────────── +@requires_bash class TestE2EFlow: def test_e2e_timestamp(self, git_repo: Path): """Test 14: E2E timestamp flow — branch, dir, validation.""" @@ -455,6 +467,7 @@ def test_e2e_sequential(self, git_repo: Path): # ── Allow Existing Branch Tests ────────────────────────────────────────────── +@requires_bash class TestAllowExistingBranch: def test_allow_existing_switches_to_branch(self, git_repo: Path): """T006: Pre-create branch, verify script switches to it.""" @@ -655,6 +668,7 @@ def test_powershell_extension_surfaces_checkout_errors(self): # ── Dry-Run Tests ──────────────────────────────────────────────────────────── +@requires_bash class TestDryRun: def test_dry_run_sequential_outputs_name(self, git_repo: Path): """T009: Dry-run computes correct branch name with existing specs.""" @@ -984,6 +998,7 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): # ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── +@requires_bash class TestGitBranchNameOverrideBash: """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" @@ -1088,6 +1103,7 @@ def test_overlong_name_rejected(self, ext_ps_git_repo: Path): class TestFeatureDirectoryResolution: """Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution.""" + @requires_bash def test_env_var_overrides_branch_lookup(self, git_repo: Path): """SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup.""" custom_dir = git_repo / "my-custom-specs" / "my-feature" @@ -1110,6 +1126,7 @@ def test_env_var_overrides_branch_lookup(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") + @requires_bash def test_feature_json_overrides_branch_lookup(self, git_repo: Path): """feature.json feature_directory takes priority over branch-based lookup.""" custom_dir = git_repo / "specs" / "custom-feature" @@ -1117,7 +1134,7 @@ def test_feature_json_overrides_branch_lookup(self, git_repo: Path): feature_json = git_repo / ".specify" / "feature.json" feature_json.write_text( - f'{{"feature_directory": "{custom_dir}"}}\n', + json.dumps({"feature_directory": str(custom_dir)}) + "\n", encoding="utf-8", ) @@ -1136,6 +1153,7 @@ def test_feature_json_overrides_branch_lookup(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") + @requires_bash def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): """Env var wins over feature.json.""" env_dir = git_repo / "specs" / "env-feature" @@ -1145,7 +1163,7 @@ def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): feature_json = git_repo / ".specify" / "feature.json" feature_json.write_text( - f'{{"feature_directory": "{json_dir}"}}\n', + json.dumps({"feature_directory": str(json_dir)}) + "\n", encoding="utf-8", ) @@ -1165,6 +1183,7 @@ def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") + @requires_bash def test_fallback_to_branch_lookup(self, git_repo: Path): """Without env var or feature.json, falls back to branch-based lookup.""" subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True) @@ -1219,7 +1238,7 @@ def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path): feature_json = git_repo / ".specify" / "feature.json" feature_json.write_text( - f'{{"feature_directory": "{custom_dir}"}}\n', + json.dumps({"feature_directory": str(custom_dir)}) + "\n", encoding="utf-8", ) From 752683d347ead385a4d94bf2415eef5ddf1939b7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:41:23 -0500 Subject: [PATCH 054/184] chore: release 0.7.1, begin 0.7.2.dev0 development (#2235) * chore: bump version to 0.7.1 * chore: begin 0.7.2.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe587098f2..c362aee259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ +## [0.7.1] - 2026-04-15 + +### Changed + +- ci: add windows-latest to test matrix (#2233) +- docs: remove deprecated --skip-tls references from local-development guide (#2231) +- fix: allow Claude to chain skills for hook execution (#2227) +- docs: merge TESTING.md into CONTRIBUTING.md, remove TESTING.md (#2228) +- Add agent-assign extension to community catalog (#2030) +- fix: unofficial PyPI warning (#1982) and legacy extension command name auto-correction (#2017) (#2027) +- feat: register architect-preview in community catalog (#2214) +- chore: deprecate --ai flag in favor of --integration on specify init (#2218) +- chore: release 0.7.0, begin 0.7.1.dev0 development (#2217) + ## [0.7.0] - 2026-04-14 ### Changed diff --git a/pyproject.toml b/pyproject.toml index e7ea248214..d7208fa389 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.1.dev0" +version = "0.7.2.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From db8131441eb70371950acea6f9157028bccd1ccb Mon Sep 17 00:00:00 2001 From: Aaron Sun Date: Thu, 16 Apr 2026 05:49:15 -0700 Subject: [PATCH 055/184] Added issues extension (#2194) * Added issues extension * Removed duplicate extension * Renamed extension * Addressed Copilot comments --------- Co-authored-by: Aaron Sun Co-authored-by: Aaron Sun Co-authored-by: Aaron Sun --- README.md | 3 ++- extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 031d663476..9985212ff2 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,8 @@ The following community-contributed extensions are available in [`catalog.commun | Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | | FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) | | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | -| GitHub Issues Integration | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | +| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | +| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) | | 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) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 6589093444..f82747a744 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -714,7 +714,7 @@ "updated_at": "2026-03-31T00:00:00Z" }, "github-issues": { - "name": "GitHub Issues Integration", + "name": "GitHub Issues Integration 1", "id": "github-issues", "description": "Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability", "author": "Fatima367", @@ -753,6 +753,38 @@ "created_at": "2026-04-12T15:30:00Z", "updated_at": "2026-04-13T14:39:00Z" }, + "issue": { + "name": "GitHub Issues Integration 2", + "id": "issue", + "description": "Creates and syncs local specs based on an existing issue in GitHub", + "author": "aaronrsun", + "version": "1.0.0", + "download_url": "https://github.com/aaronrsun/spec-kit-issue/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/aaronrsun/spec-kit-issue", + "homepage": "https://github.com/aaronrsun/spec-kit-issue", + "documentation": "https://github.com/aaronrsun/spec-kit-issue/blob/main/README.md", + "changelog": "https://github.com/aaronrsun/spec-kit-issue/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "issue", + "integration", + "github", + "issues", + "sync" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-04T00:00:00Z", + "updated_at": "2026-04-04T00:00:00Z" + }, "iterate": { "name": "Iterate", "id": "iterate", From e0fd355dad45a0b63a376d85d210f4a5d8ca973b Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Thu, 16 Apr 2026 18:57:25 +0500 Subject: [PATCH 056/184] Add Catalog CI extension to community catalog (#2239) - Adds catalog-ci entry to catalog.community.json (between canon and ci-guard) - Adds Catalog CI row to community extensions table in README.md - Bumps top-level updated_at --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9985212ff2..b48c829491 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ The following community-contributed extensions are available in [`catalog.commun | Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) | | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | | Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) | +| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) | | CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) | | Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) | | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index f82747a744..9e06b8e255 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-14T21:30:00Z", + "updated_at": "2026-04-16T18:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -301,6 +301,38 @@ "created_at": "2026-03-29T00:00:00Z", "updated_at": "2026-03-29T00:00:00Z" }, + "catalog-ci": { + "name": "Catalog CI", + "id": "catalog-ci", + "description": "Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-catalog-ci/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "ci", + "validation", + "catalog", + "quality", + "automation" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + }, "ci-guard": { "name": "CI Guard", "id": "ci-guard", From 282dd3da569b95053cafe4573b5ce6c24dc610fa Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 08:59:32 -0500 Subject: [PATCH 057/184] =?UTF-8?q?feat:=20Integration=20catalog=20?= =?UTF-8?q?=E2=80=94=20discovery,=20versioning,=20and=20community=20distri?= =?UTF-8?q?bution=20(#2130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat: add integration catalog system with catalog files, IntegrationCatalog class, list --catalog flag, upgrade command, integration.yml descriptor, and tests Agent-Logs-Url: https://github.com/github/spec-kit/sessions/bbcd44e8-c69c-4735-adc1-bdf1ce109184 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: address PR review feedback - Replace empty except with cache cleanup in _fetch_single_catalog - Log teardown failure warning instead of silent pass in upgrade - Validate catalog_data and integrations are dicts before use - Catch OSError/UnicodeError in IntegrationDescriptor._load - Add isinstance checks for integration/requires/provides/commands - Enforce semver (X.Y.Z) instead of PEP 440 for descriptor versions - Fix docstring and CONTRIBUTING.md to match actual block-on-modified behavior - Restore old manifest on upgrade failure for transactional safety * refactor: address second round of PR review feedback - Remove dead cache_file/cache_metadata_file attributes from IntegrationCatalog - Deduplicate non-default catalog warning (show once per process) - Anchor version regex to reject partial matches like 1.0.0beta - Fix 'Preserved modified' message to 'Skipped' for accuracy - Make upgrade transactional: install new files first, then remove stale old-only files, so a failed setup leaves old integration intact - Update CONTRIBUTING.md: speckit_version validates presence only * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: address third round of PR review feedback - Fix CONTRIBUTING.md JSON examples to show full catalog structure with schema_version and integrations wrapper - Wrap cache writes in try/except OSError for read-only project dirs - Validate _load_catalog_config YAML root is a dict - Skip non-dict integ_data entries in merged catalog - Normalize tags to list-of-strings before filtering/searching - Add path traversal containment check for stale file deletion - Clarify docstring: lower numeric priority = higher precedence * fix: address fourth round of PR review feedback - Remove unused _write_catalog helper from test file - Fix comment: tests use monkeypatched urlopen, not file:// URLs - Wrap cache unlink calls in OSError handler - Add explicit encoding='utf-8' to all cache read_text/write_text calls - Restore packaging.version.Version for descriptor version validation to align with extension/preset validators - Add missing goose entry to integrations/catalog.json * fix: remove unused Path import, add comment to empty except * fix: validate descriptor root is dict, add shared infra to upgrade - Add isinstance(self.data, dict) check at start of _validate() so non-mapping YAML roots raise IntegrationDescriptorError - Run _install_shared_infra() and ensure_executable_scripts() in upgrade command to match install/switch behavior * fix: address sixth round of PR review feedback - Validate integration.id/name/version/description are strings - Catch TypeError in pkg_version.Version() for non-string versions - Swap validation order: check catalogs type before emptiness - Isolate TestActiveCatalogs from user ~/.specify/ via monkeypatch * fix: address seventh round of PR review feedback - Update docs: version field uses PEP 440, not semver - Harden search() against non-string author/name/description fields - Validate requires.speckit_version is a non-empty string - Validate command name/file are non-empty strings, file is safe relative path - Handle stale symlinks in upgrade cleanup - Document catalog configuration stack in README.md * fix: validate script entries, remove destructive teardown from upgrade rollback - Validate provides.scripts entries are non-empty strings with safe relative paths - Remove teardown from upgrade rollback since setup overwrites in-place — teardown would delete files that were working before the upgrade * fix: use consistent resolved root for stale-file cleanup paths * fix: validate redirect URL and reject drive-qualified paths - Validate final URL after redirects with _validate_catalog_url() - Reject paths with Path.drive or Path.anchor for Windows safety - Update FakeResponse mocks with geturl() method * fix: fix docstring backticks, assert file modification in upgrade tests * docs: clarify directory naming convention for hyphenated integration keys * fix: correct key type hint, isolate all catalog tests from env - Fix key parameter type to str | None (defaults to None) - Add HOME/USERPROFILE monkeypatch and clear SPECKIT_INTEGRATION_CATALOG_URL in all TestCatalogFetch tests for full environment isolation * fix: neutralize catalog table title, handle non-dict cache metadata * fix: validate requires.tools entries in descriptor * fix: show discovery-only status, clear metadata files in clear_cache * fix: catch OSError/UnicodeError in cache read path * refactor: reuse IntegrationManifest.uninstall for stale-file cleanup * fix: normalize null tools to empty list in descriptor accessor --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- integrations/CONTRIBUTING.md | 142 ++++ integrations/README.md | 129 ++++ integrations/catalog.community.json | 6 + integrations/catalog.json | 259 +++++++ src/specify_cli/__init__.py | 162 ++++- src/specify_cli/integrations/catalog.py | 626 +++++++++++++++++ .../integrations/test_integration_catalog.py | 656 ++++++++++++++++++ 7 files changed, 1979 insertions(+), 1 deletion(-) create mode 100644 integrations/CONTRIBUTING.md create mode 100644 integrations/README.md create mode 100644 integrations/catalog.community.json create mode 100644 integrations/catalog.json create mode 100644 src/specify_cli/integrations/catalog.py create mode 100644 tests/integrations/test_integration_catalog.py diff --git a/integrations/CONTRIBUTING.md b/integrations/CONTRIBUTING.md new file mode 100644 index 0000000000..77a50d4d98 --- /dev/null +++ b/integrations/CONTRIBUTING.md @@ -0,0 +1,142 @@ +# Contributing to the Integration Catalog + +This guide covers adding integrations to both the **built-in** and **community** catalogs. + +## Adding a Built-In Integration + +Built-in integrations are maintained by the Spec Kit core team and ship with the CLI. + +### Checklist + +1. **Create the integration subpackage** under `src/specify_cli/integrations//` + — `` matches the integration key when it contains no hyphens (e.g., `gemini`), or replaces hyphens with underscores when it does (e.g., key `cursor-agent` → directory `cursor_agent/`, key `kiro-cli` → directory `kiro_cli/`). Python package names cannot use hyphens. +2. **Implement the integration class** extending `MarkdownIntegration`, `TomlIntegration`, or `SkillsIntegration` +3. **Register the integration** in `src/specify_cli/integrations/__init__.py` +4. **Add tests** under `tests/integrations/test_integration_.py` +5. **Add a catalog entry** in `integrations/catalog.json` +6. **Update documentation** in `AGENTS.md` and `README.md` + +### Catalog Entry Format + +Add your integration under the top-level `integrations` key in `integrations/catalog.json`: + +```json +{ + "schema_version": "1.0", + "integrations": { + "my-agent": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + } + } +} +``` + +## Adding a Community Integration + +Community integrations are contributed by external developers and listed in `integrations/catalog.community.json` for discovery. + +### Prerequisites + +1. **Working integration** — tested with `specify integration install` +2. **Public repository** — hosted on GitHub or similar +3. **`integration.yml` descriptor** — valid descriptor file (see below) +4. **Documentation** — README with usage instructions +5. **License** — open source license file + +### `integration.yml` Descriptor + +Every community integration must include an `integration.yml`: + +```yaml +schema_version: "1.0" +integration: + id: "my-agent" + name: "My Agent" + version: "1.0.0" + description: "Integration for My Agent" + author: "your-name" + repository: "https://github.com/your-name/speckit-my-agent" + license: "MIT" +requires: + speckit_version: ">=0.6.0" + tools: + - name: "my-agent" + version: ">=1.0.0" + required: true +provides: + commands: + - name: "speckit.specify" + file: "templates/speckit.specify.md" + scripts: + - update-context.sh +``` + +### Descriptor Validation Rules + +| Field | Rule | +|-------|------| +| `schema_version` | Must be `"1.0"` | +| `integration.id` | Lowercase alphanumeric + hyphens (`^[a-z0-9-]+$`) | +| `integration.version` | Valid PEP 440 version (parsed with `packaging.version.Version()`) | +| `requires.speckit_version` | Required field; specify a version constraint such as `>=0.6.0` (current validation checks presence only) | +| `provides` | Must include at least one command or script | +| `provides.commands[].name` | String identifier | +| `provides.commands[].file` | Relative path to template file | + +### Submitting to the Community Catalog + +1. **Fork** the [spec-kit repository](https://github.com/github/spec-kit) +2. **Add your entry** under the `integrations` key in `integrations/catalog.community.json`: + +```json +{ + "schema_version": "1.0", + "integrations": { + "my-agent": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "your-name", + "repository": "https://github.com/your-name/speckit-my-agent", + "tags": ["cli"] + } + } +} +``` + +3. **Open a pull request** with: + - Your catalog entry + - Link to your integration repository + - Confirmation that `integration.yml` is valid + +### Version Updates + +To update your integration version in the catalog: + +1. Release a new version of your integration +2. Open a PR updating the `version` field in `catalog.community.json` +3. Ensure backward compatibility or document breaking changes + +## Upgrade Workflow + +The `specify integration upgrade` command supports diff-aware upgrades: + +1. **Hash comparison** — the manifest records SHA-256 hashes of all installed files +2. **Modified file detection** — files changed since installation are flagged +3. **Safe default** — the upgrade blocks if any installed files were modified since installation +4. **Forced reinstall** — passing `--force` overwrites modified files with the latest version + +```bash +# Upgrade current integration (blocks if files are modified) +specify integration upgrade + +# Force upgrade (overwrites modified files) +specify integration upgrade --force +``` diff --git a/integrations/README.md b/integrations/README.md new file mode 100644 index 0000000000..b755e0416d --- /dev/null +++ b/integrations/README.md @@ -0,0 +1,129 @@ +# Spec Kit Integration Catalog + +The integration catalog enables discovery, versioning, and distribution of AI agent integrations for Spec Kit. + +## Catalog Files + +### Built-In Catalog (`catalog.json`) + +Contains integrations that ship with Spec Kit. These are maintained by the core team and always installable. + +### Community Catalog (`catalog.community.json`) + +Community-contributed integrations. Listed for discovery only — users install from the source repositories. + +## Catalog Configuration + +The catalog stack is resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_INTEGRATION_CATALOG_URL` overrides all catalogs with a single URL +2. **Project config** — `.specify/integration-catalogs.yml` in the project root +3. **User config** — `~/.specify/integration-catalogs.yml` in the user home directory +4. **Built-in defaults** — `catalog.json` + `catalog.community.json` + +Example `integration-catalogs.yml`: + +```yaml +catalogs: + - url: "https://example.com/my-catalog.json" + name: "my-catalog" + priority: 1 + install_allowed: true +``` + +## CLI Commands + +```bash +# List built-in integrations (default) +specify integration list + +# Browse full catalog (built-in + community) +specify integration list --catalog + +# Install an integration +specify integration install copilot + +# Upgrade the current integration (diff-aware) +specify integration upgrade + +# Upgrade with force (overwrite modified files) +specify integration upgrade --force +``` + +## Integration Descriptor (`integration.yml`) + +Each integration can include an `integration.yml` descriptor that documents its metadata, requirements, and provided commands/scripts: + +```yaml +schema_version: "1.0" +integration: + id: "my-agent" + name: "My Agent" + version: "1.0.0" + description: "Integration for My Agent" + author: "my-org" + repository: "https://github.com/my-org/speckit-my-agent" + license: "MIT" +requires: + speckit_version: ">=0.6.0" + tools: + - name: "my-agent" + version: ">=1.0.0" + required: true +provides: + commands: + - name: "speckit.specify" + file: "templates/speckit.specify.md" + - name: "speckit.plan" + file: "templates/speckit.plan.md" + scripts: + - update-context.sh + - update-context.ps1 +``` + +## Catalog Schema + +Both catalog files follow the same JSON schema: + +```json +{ + "schema_version": "1.0", + "updated_at": "2026-04-08T00:00:00Z", + "catalog_url": "https://...", + "integrations": { + "my-agent": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "my-org", + "repository": "https://github.com/my-org/speckit-my-agent", + "tags": ["cli"] + } + } +} +``` + +### Required Fields + +| Field | Type | Description | +|-------|------|-------------| +| `schema_version` | string | Must be `"1.0"` | +| `updated_at` | string | ISO 8601 timestamp | +| `integrations` | object | Map of integration ID → metadata | + +### Integration Entry Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | Yes | Unique ID (lowercase alphanumeric + hyphens) | +| `name` | string | Yes | Human-readable display name | +| `version` | string | Yes | PEP 440 version (e.g., `1.0.0`, `1.0.0a1`) | +| `description` | string | Yes | One-line description | +| `author` | string | No | Author name or organization | +| `repository` | string | No | Source repository URL | +| `tags` | array | No | Searchable tags (e.g., `["cli", "ide"]`) | + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add integrations to the community catalog. diff --git a/integrations/catalog.community.json b/integrations/catalog.community.json new file mode 100644 index 0000000000..47eb6d550d --- /dev/null +++ b/integrations/catalog.community.json @@ -0,0 +1,6 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-08T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json", + "integrations": {} +} diff --git a/integrations/catalog.json b/integrations/catalog.json new file mode 100644 index 0000000000..3df96b8789 --- /dev/null +++ b/integrations/catalog.json @@ -0,0 +1,259 @@ +{ + "schema_version": "1.0", + "updated_at": "2026-04-08T00:00:00Z", + "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", + "integrations": { + "claude": { + "id": "claude", + "name": "Claude Code", + "version": "1.0.0", + "description": "Anthropic Claude Code CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "anthropic"] + }, + "copilot": { + "id": "copilot", + "name": "GitHub Copilot", + "version": "1.0.0", + "description": "GitHub Copilot IDE integration with agent commands and prompt files", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "github"] + }, + "gemini": { + "id": "gemini", + "name": "Gemini CLI", + "version": "1.0.0", + "description": "Google Gemini CLI integration with TOML command format", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "google"] + }, + "cursor-agent": { + "id": "cursor-agent", + "name": "Cursor", + "version": "1.0.0", + "description": "Cursor IDE integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "windsurf": { + "id": "windsurf", + "name": "Windsurf", + "version": "1.0.0", + "description": "Windsurf IDE workflow integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "amp": { + "id": "amp", + "name": "Amp", + "version": "1.0.0", + "description": "Amp CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "codex": { + "id": "codex", + "name": "Codex CLI", + "version": "1.0.0", + "description": "Codex CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] + }, + "qwen": { + "id": "qwen", + "name": "Qwen Code", + "version": "1.0.0", + "description": "Alibaba Qwen Code CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "alibaba"] + }, + "opencode": { + "id": "opencode", + "name": "opencode", + "version": "1.0.0", + "description": "opencode CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "forge": { + "id": "forge", + "name": "Forge", + "version": "1.0.0", + "description": "Forge CLI integration with parameter-based commands", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "kiro-cli": { + "id": "kiro-cli", + "name": "Kiro CLI", + "version": "1.0.0", + "description": "Kiro CLI prompt-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "junie": { + "id": "junie", + "name": "Junie", + "version": "1.0.0", + "description": "Junie by JetBrains CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "jetbrains"] + }, + "auggie": { + "id": "auggie", + "name": "Auggie CLI", + "version": "1.0.0", + "description": "Auggie CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "shai": { + "id": "shai", + "name": "SHAI", + "version": "1.0.0", + "description": "SHAI CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "tabnine": { + "id": "tabnine", + "name": "Tabnine CLI", + "version": "1.0.0", + "description": "Tabnine CLI integration with TOML command format", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "kilocode": { + "id": "kilocode", + "name": "Kilo Code", + "version": "1.0.0", + "description": "Kilo Code IDE workflow integration", + "author": "spec-kit-core", + "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"] + }, + "bob": { + "id": "bob", + "name": "IBM Bob", + "version": "1.0.0", + "description": "IBM Bob IDE integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "ibm"] + }, + "trae": { + "id": "trae", + "name": "Trae", + "version": "1.0.0", + "description": "Trae IDE rules-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide"] + }, + "codebuddy": { + "id": "codebuddy", + "name": "CodeBuddy", + "version": "1.0.0", + "description": "CodeBuddy CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "qodercli": { + "id": "qodercli", + "name": "Qoder CLI", + "version": "1.0.0", + "description": "Qoder CLI integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "kimi": { + "id": "kimi", + "name": "Kimi Code", + "version": "1.0.0", + "description": "Kimi Code CLI skills-based integration by Moonshot AI", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] + }, + "pi": { + "id": "pi", + "name": "Pi Coding Agent", + "version": "1.0.0", + "description": "Pi terminal coding agent prompt-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "iflow": { + "id": "iflow", + "name": "iFlow CLI", + "version": "1.0.0", + "description": "iFlow CLI integration by iflow-ai", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + }, + "vibe": { + "id": "vibe", + "name": "Mistral Vibe", + "version": "1.0.0", + "description": "Mistral Vibe CLI prompt-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "mistral"] + }, + "agy": { + "id": "agy", + "name": "Antigravity", + "version": "1.0.0", + "description": "Antigravity IDE skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "skills"] + }, + "generic": { + "id": "generic", + "name": "Generic (bring your own agent)", + "version": "1.0.0", + "description": "Generic integration for any agent via --ai-commands-dir", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["generic"] + }, + "goose": { + "id": "goose", + "name": "Goose", + "version": "1.0.0", + "description": "Goose CLI integration with YAML recipe format", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli"] + } + } +} diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b7b0c909a5..9f895cb3b9 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1775,7 +1775,9 @@ def _resolve_script_type(project_root: Path, script_type: str | None) -> str: @integration_app.command("list") -def integration_list(): +def integration_list( + catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), +): """List available integrations and installed status.""" from .integrations import INTEGRATION_REGISTRY @@ -1790,6 +1792,50 @@ def integration_list(): current = _read_integration_json(project_root) installed_key = current.get("integration") + if catalog: + from .integrations.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") + + 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 == installed_key: + status = "[green]installed[/green]" + elif eid in INTEGRATION_REGISTRY: + status = "built-in" + elif install_allowed is False: + status = "discovery-only" + else: + status = "" + table.add_row( + eid, + entry.get("name", eid), + entry.get("version", ""), + cat_name, + status, + ) + + console.print(table) + return + table = Table(title="AI Agent Integrations") table.add_column("Key", style="cyan") table.add_column("Name") @@ -2176,6 +2222,120 @@ def integration_switch( 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 .integrations import get_integration + from .integrations.manifest import IntegrationManifest + + project_root = Path.cwd() + + specify_dir = project_root / ".specify" + if not specify_dir.exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + current = _read_integration_json(project_root) + installed_key = current.get("integration") + + 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 installed_key and installed_key != key: + console.print( + f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}')." + ) + console.print(f"Use [cyan]specify integration switch {key}[/cyan] instead.") + 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 (ValueError, FileNotFoundError) 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_script_type(project_root, script) + + # Ensure shared infrastructure is present (safe to run unconditionally; + # _install_shared_infra merges missing files without overwriting). + _install_shared_infra(project_root, selected_script) + if os.name != "nt": + 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()) + + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + + try: + integration.setup( + project_root, + new_manifest, + parsed_options=parsed_options, + script_type=selected_script, + raw_options=integration_options, + ) + new_manifest.save() + _write_integration_json(project_root, key, selected_script) + _update_init_options_for_integration(project_root, integration, script_type=selected_script) + 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 upgrade integration: {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") + + # ===== Preset Commands ===== diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py new file mode 100644 index 0000000000..2faa69ae96 --- /dev/null +++ b/src/specify_cli/integrations/catalog.py @@ -0,0 +1,626 @@ +"""Integration catalog — discovery, validation, and upgrade support. + +Provides: +- ``IntegrationCatalogEntry`` — single catalog source metadata. +- ``IntegrationCatalog`` — fetches, caches, and searches integration + catalogs (built-in + community). +- ``IntegrationDescriptor`` — loads and validates ``integration.yml``. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml +from packaging import version as pkg_version + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +class IntegrationCatalogError(Exception): + """Raised when a catalog operation fails.""" + + +class IntegrationDescriptorError(Exception): + """Raised when an integration.yml descriptor is invalid.""" + + +# --------------------------------------------------------------------------- +# IntegrationCatalogEntry +# --------------------------------------------------------------------------- + +@dataclass +class IntegrationCatalogEntry: + """Represents a single catalog source in the catalog stack.""" + + url: str + name: str + priority: int + install_allowed: bool + description: str = "" + + +# --------------------------------------------------------------------------- +# IntegrationCatalog +# --------------------------------------------------------------------------- + +class IntegrationCatalog: + """Manages integration catalog fetching, caching, and searching.""" + + DEFAULT_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json" + ) + COMMUNITY_CATALOG_URL = ( + "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.community.json" + ) + CACHE_DURATION = 3600 # 1 hour + + def __init__(self, project_root: Path) -> None: + self.project_root = project_root + self.cache_dir = project_root / ".specify" / "integrations" / ".cache" + + # -- URL validation --------------------------------------------------- + + @staticmethod + def _validate_catalog_url(url: str) -> None: + from urllib.parse import urlparse + + parsed = urlparse(url) + is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost): + raise IntegrationCatalogError( + f"Catalog URL must use HTTPS (got {parsed.scheme}://). " + "HTTP is only allowed for localhost." + ) + if not parsed.netloc: + raise IntegrationCatalogError( + "Catalog URL must be a valid URL with a host." + ) + + # -- Catalog stack ---------------------------------------------------- + + def _load_catalog_config( + self, config_path: Path + ) -> Optional[List[IntegrationCatalogEntry]]: + """Load catalog stack from a YAML file. + + Returns None when the file does not exist. + + Raises: + IntegrationCatalogError: on invalid content + """ + if not config_path.exists(): + return None + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise IntegrationCatalogError( + f"Failed to read catalog config {config_path}: {exc}" + ) + if not isinstance(data, dict): + raise IntegrationCatalogError( + f"Invalid catalog config {config_path}: expected a YAML mapping at the root" + ) + catalogs_data = data.get("catalogs", []) + if not isinstance(catalogs_data, list): + raise IntegrationCatalogError( + f"Invalid catalog config: 'catalogs' must be a list, " + f"got {type(catalogs_data).__name__}" + ) + if not catalogs_data: + raise IntegrationCatalogError( + f"Catalog config {config_path} exists but contains no 'catalogs' entries. " + f"Remove the file to use built-in defaults, or add valid catalog entries." + ) + entries: List[IntegrationCatalogEntry] = [] + skipped: List[int] = [] + for idx, item in enumerate(catalogs_data): + if not isinstance(item, dict): + raise IntegrationCatalogError( + f"Invalid catalog entry at index {idx}: " + f"expected a mapping, got {type(item).__name__}" + ) + url = str(item.get("url", "")).strip() + if not url: + skipped.append(idx) + continue + self._validate_catalog_url(url) + try: + priority = int(item.get("priority", idx + 1)) + except (TypeError, ValueError): + raise IntegrationCatalogError( + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {item.get('priority')!r}" + ) + raw_install = item.get("install_allowed", False) + if isinstance(raw_install, str): + install_allowed = raw_install.strip().lower() in ("true", "yes", "1") + else: + install_allowed = bool(raw_install) + entries.append( + IntegrationCatalogEntry( + url=url, + name=str(item.get("name", f"catalog-{idx + 1}")), + priority=priority, + install_allowed=install_allowed, + description=str(item.get("description", "")), + ) + ) + entries.sort(key=lambda e: e.priority) + if not entries: + raise IntegrationCatalogError( + f"Catalog config {config_path} contains {len(catalogs_data)} " + f"entries but none have valid URLs (entries at indices {skipped} " + f"were skipped). Each catalog entry must have a 'url' field." + ) + return entries + + def get_active_catalogs(self) -> List[IntegrationCatalogEntry]: + """Return the ordered list of active integration catalogs. + + Resolution: + 1. ``SPECKIT_INTEGRATION_CATALOG_URL`` env var + 2. Project ``.specify/integration-catalogs.yml`` + 3. User ``~/.specify/integration-catalogs.yml`` + 4. Built-in defaults (built-in + community) + """ + import sys + + env_value = os.environ.get("SPECKIT_INTEGRATION_CATALOG_URL", "").strip() + if env_value: + self._validate_catalog_url(env_value) + if env_value != self.DEFAULT_CATALOG_URL: + if not getattr(self, "_non_default_catalog_warning_shown", False): + print( + "Warning: Using non-default integration catalog. " + "Only use catalogs from sources you trust.", + file=sys.stderr, + ) + self._non_default_catalog_warning_shown = True + return [ + IntegrationCatalogEntry( + url=env_value, + name="custom", + priority=1, + install_allowed=True, + description="Custom catalog via SPECKIT_INTEGRATION_CATALOG_URL", + ) + ] + + project_cfg = self.project_root / ".specify" / "integration-catalogs.yml" + catalogs = self._load_catalog_config(project_cfg) + if catalogs is not None: + return catalogs + + user_cfg = Path.home() / ".specify" / "integration-catalogs.yml" + catalogs = self._load_catalog_config(user_cfg) + if catalogs is not None: + return catalogs + + return [ + IntegrationCatalogEntry( + url=self.DEFAULT_CATALOG_URL, + name="default", + priority=1, + install_allowed=True, + description="Built-in catalog of installable integrations", + ), + IntegrationCatalogEntry( + url=self.COMMUNITY_CATALOG_URL, + name="community", + priority=2, + install_allowed=False, + description="Community-contributed integrations (discovery only)", + ), + ] + + # -- Fetching --------------------------------------------------------- + + def _fetch_single_catalog( + self, + entry: IntegrationCatalogEntry, + force_refresh: bool = False, + ) -> Dict[str, Any]: + """Fetch one catalog, with per-URL caching.""" + import urllib.error + import urllib.request + + url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16] + cache_file = self.cache_dir / f"catalog-{url_hash}.json" + cache_meta = self.cache_dir / f"catalog-{url_hash}-metadata.json" + + if not force_refresh and cache_file.exists() and cache_meta.exists(): + try: + meta = json.loads(cache_meta.read_text(encoding="utf-8")) + cached_at = datetime.fromisoformat(meta.get("cached_at", "")) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - cached_at).total_seconds() + if age < self.CACHE_DURATION: + return json.loads(cache_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError): + # Cache is invalid or stale metadata; delete and refetch from source. + try: + cache_file.unlink(missing_ok=True) + cache_meta.unlink(missing_ok=True) + except OSError: + pass # Cache cleanup is best-effort; ignore deletion failures. + + try: + with urllib.request.urlopen(entry.url, timeout=10) as resp: + # Validate final URL after redirects + final_url = resp.geturl() + if final_url != entry.url: + self._validate_catalog_url(final_url) + catalog_data = json.loads(resp.read()) + + if not isinstance(catalog_data, dict): + raise IntegrationCatalogError( + f"Invalid catalog format from {entry.url}: expected a JSON object" + ) + if ( + "schema_version" not in catalog_data + or "integrations" not in catalog_data + ): + raise IntegrationCatalogError( + f"Invalid catalog format from {entry.url}" + ) + if not isinstance(catalog_data.get("integrations"), dict): + raise IntegrationCatalogError( + f"Invalid catalog format from {entry.url}: 'integrations' must be a JSON object" + ) + + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(catalog_data, indent=2), encoding="utf-8") + cache_meta.write_text( + json.dumps( + { + "cached_at": datetime.now(timezone.utc).isoformat(), + "catalog_url": entry.url, + }, + indent=2, + ), + encoding="utf-8", + ) + except OSError: + pass # Cache is best-effort; proceed with fetched data + return catalog_data + + except urllib.error.URLError as exc: + raise IntegrationCatalogError( + f"Failed to fetch catalog from {entry.url}: {exc}" + ) + except json.JSONDecodeError as exc: + raise IntegrationCatalogError( + f"Invalid JSON in catalog from {entry.url}: {exc}" + ) + + def _get_merged_integrations( + self, force_refresh: bool = False + ) -> List[Dict[str, Any]]: + """Fetch and merge integrations from all active catalogs. + + Catalogs are processed in the order returned by + :meth:`get_active_catalogs`. On conflicts, the first catalog in that + order wins (lower numeric priority = higher precedence). Each dict is + annotated with ``_catalog_name`` and ``_install_allowed``. + """ + import sys + + active = self.get_active_catalogs() + merged: Dict[str, Dict[str, Any]] = {} + any_success = False + + for entry in active: + try: + data = self._fetch_single_catalog(entry, force_refresh) + any_success = True + except IntegrationCatalogError as exc: + print( + f"Warning: Could not fetch catalog '{entry.name}': {exc}", + file=sys.stderr, + ) + continue + + for integ_id, integ_data in data.get("integrations", {}).items(): + if not isinstance(integ_data, dict): + continue + if integ_id not in merged: + merged[integ_id] = { + **integ_data, + "id": integ_id, + "_catalog_name": entry.name, + "_install_allowed": entry.install_allowed, + } + + if not any_success and active: + raise IntegrationCatalogError( + "Failed to fetch any integration catalog" + ) + + return list(merged.values()) + + # -- Search / info ---------------------------------------------------- + + def search( + self, + query: Optional[str] = None, + tag: Optional[str] = None, + author: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """Search catalogs for integrations matching the given filters.""" + results: List[Dict[str, Any]] = [] + for item in self._get_merged_integrations(): + author_val = item.get("author", "") + if not isinstance(author_val, str): + author_val = str(author_val) if author_val is not None else "" + if author and author_val.lower() != author.lower(): + continue + if tag: + raw_tags = item.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + if tag.lower() not in [t.lower() for t in tags_list if isinstance(t, str)]: + continue + if query: + raw_tags = item.get("tags", []) + tags_list = raw_tags if isinstance(raw_tags, list) else [] + name_val = item.get("name", "") + desc_val = item.get("description", "") + id_val = item.get("id", "") + haystack = " ".join( + [ + str(name_val) if name_val else "", + str(desc_val) if desc_val else "", + str(id_val) if id_val else "", + ] + + [t for t in tags_list if isinstance(t, str)] + ).lower() + if query.lower() not in haystack: + continue + results.append(item) + return results + + def get_integration_info( + self, integration_id: str + ) -> Optional[Dict[str, Any]]: + """Return catalog metadata for a single integration, or None.""" + for item in self._get_merged_integrations(): + if item["id"] == integration_id: + return item + return None + + # -- Cache management ------------------------------------------------- + + def clear_cache(self) -> None: + """Remove all cached catalog files.""" + if self.cache_dir.exists(): + for pattern in ("catalog-*.json", "catalog-*-metadata.json"): + for f in self.cache_dir.glob(pattern): + f.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# IntegrationDescriptor (integration.yml) +# --------------------------------------------------------------------------- + +class IntegrationDescriptor: + """Loads and validates an ``integration.yml`` descriptor. + + The descriptor mirrors ``extension.yml`` and ``preset.yml``:: + + schema_version: "1.0" + integration: + id: "my-agent" + name: "My Agent" + version: "1.0.0" + description: "Integration for My Agent" + author: "my-org" + requires: + speckit_version: ">=0.6.0" + tools: [...] + provides: + commands: [...] + scripts: [...] + """ + + SCHEMA_VERSION = "1.0" + REQUIRED_TOP_LEVEL = ["schema_version", "integration", "requires", "provides"] + + def __init__(self, descriptor_path: Path) -> None: + self.path = descriptor_path + self.data = self._load(descriptor_path) + self._validate() + + # -- Loading ---------------------------------------------------------- + + @staticmethod + def _load(path: Path) -> dict: + try: + with open(path, "r", encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + except yaml.YAMLError as exc: + raise IntegrationDescriptorError(f"Invalid YAML in {path}: {exc}") + except FileNotFoundError: + raise IntegrationDescriptorError(f"Descriptor not found: {path}") + except (OSError, UnicodeError) as exc: + raise IntegrationDescriptorError( + f"Unable to read descriptor {path}: {exc}" + ) + + # -- Validation ------------------------------------------------------- + + def _validate(self) -> None: + if not isinstance(self.data, dict): + raise IntegrationDescriptorError( + f"Descriptor root must be a YAML mapping, got {type(self.data).__name__}" + ) + for field in self.REQUIRED_TOP_LEVEL: + if field not in self.data: + raise IntegrationDescriptorError( + f"Missing required field: {field}" + ) + + if self.data["schema_version"] != self.SCHEMA_VERSION: + raise IntegrationDescriptorError( + f"Unsupported schema version: {self.data['schema_version']} " + f"(expected {self.SCHEMA_VERSION})" + ) + + integ = self.data["integration"] + if not isinstance(integ, dict): + raise IntegrationDescriptorError( + "'integration' must be a mapping" + ) + for field in ("id", "name", "version", "description"): + if field not in integ: + raise IntegrationDescriptorError( + f"Missing integration.{field}" + ) + if not isinstance(integ[field], str): + raise IntegrationDescriptorError( + f"integration.{field} must be a string, got {type(integ[field]).__name__}" + ) + + if not re.match(r"^[a-z0-9-]+$", integ["id"]): + raise IntegrationDescriptorError( + f"Invalid integration ID '{integ['id']}': " + "must be lowercase alphanumeric with hyphens only" + ) + + try: + pkg_version.Version(integ["version"]) + except (pkg_version.InvalidVersion, TypeError): + raise IntegrationDescriptorError( + f"Invalid version '{integ['version']}'" + ) + + requires = self.data["requires"] + if not isinstance(requires, dict): + raise IntegrationDescriptorError( + "'requires' must be a mapping" + ) + if "speckit_version" not in requires: + raise IntegrationDescriptorError( + "Missing requires.speckit_version" + ) + if not isinstance(requires["speckit_version"], str) or not requires["speckit_version"].strip(): + raise IntegrationDescriptorError( + "requires.speckit_version must be a non-empty string" + ) + tools = requires.get("tools") + if tools is not None: + if not isinstance(tools, list): + raise IntegrationDescriptorError( + "requires.tools must be a list" + ) + for tool in tools: + if not isinstance(tool, dict): + raise IntegrationDescriptorError( + "Each requires.tools entry must be a mapping" + ) + tool_name = tool.get("name") + if not isinstance(tool_name, str) or not tool_name.strip(): + raise IntegrationDescriptorError( + "requires.tools entry 'name' must be a non-empty string" + ) + + provides = self.data["provides"] + if not isinstance(provides, dict): + raise IntegrationDescriptorError( + "'provides' must be a mapping" + ) + commands = provides.get("commands", []) + scripts = provides.get("scripts", []) + if "commands" in provides and not isinstance(commands, list): + raise IntegrationDescriptorError( + "Invalid provides.commands: expected a list" + ) + if "scripts" in provides and not isinstance(scripts, list): + raise IntegrationDescriptorError( + "Invalid provides.scripts: expected a list" + ) + if not commands and not scripts: + raise IntegrationDescriptorError( + "Integration must provide at least one command or script" + ) + for cmd in commands: + if not isinstance(cmd, dict): + raise IntegrationDescriptorError( + "Each command entry must be a mapping" + ) + if "name" not in cmd or "file" not in cmd: + raise IntegrationDescriptorError( + "Command entry missing 'name' or 'file'" + ) + cmd_name = cmd["name"] + cmd_file = cmd["file"] + if not isinstance(cmd_name, str) or not cmd_name.strip(): + raise IntegrationDescriptorError( + "Command entry 'name' must be a non-empty string" + ) + if not isinstance(cmd_file, str) or not cmd_file.strip(): + raise IntegrationDescriptorError( + "Command entry 'file' must be a non-empty string" + ) + if os.path.isabs(cmd_file) or ".." in Path(cmd_file).parts or Path(cmd_file).drive or Path(cmd_file).anchor: + raise IntegrationDescriptorError( + f"Command entry 'file' must be a relative path without '..': {cmd_file}" + ) + for script_entry in scripts: + if not isinstance(script_entry, str) or not script_entry.strip(): + raise IntegrationDescriptorError( + "Script entry must be a non-empty string" + ) + if os.path.isabs(script_entry) or ".." in Path(script_entry).parts or Path(script_entry).drive or Path(script_entry).anchor: + raise IntegrationDescriptorError( + f"Script entry must be a relative path without '..': {script_entry}" + ) + + # -- Property accessors ----------------------------------------------- + + @property + def id(self) -> str: + return self.data["integration"]["id"] + + @property + def name(self) -> str: + return self.data["integration"]["name"] + + @property + def version(self) -> str: + return self.data["integration"]["version"] + + @property + def description(self) -> str: + return self.data["integration"]["description"] + + @property + def requires_speckit_version(self) -> str: + return self.data["requires"]["speckit_version"] + + @property + def commands(self) -> List[Dict[str, Any]]: + return self.data.get("provides", {}).get("commands", []) + + @property + def scripts(self) -> List[str]: + return self.data.get("provides", {}).get("scripts", []) + + @property + def tools(self) -> List[Dict[str, Any]]: + return self.data.get("requires", {}).get("tools") or [] + + def get_hash(self) -> str: + """SHA-256 hash of the descriptor file.""" + with open(self.path, "rb") as fh: + return f"sha256:{hashlib.sha256(fh.read()).hexdigest()}" diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py new file mode 100644 index 0000000000..3d0a14acdc --- /dev/null +++ b/tests/integrations/test_integration_catalog.py @@ -0,0 +1,656 @@ +"""Tests for the integration catalog system (catalog.py).""" + +import json +import os + +import pytest +import yaml + +from specify_cli.integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogEntry, + IntegrationCatalogError, + IntegrationDescriptor, + IntegrationDescriptorError, +) + + +# --------------------------------------------------------------------------- +# IntegrationCatalogEntry +# --------------------------------------------------------------------------- + + +class TestIntegrationCatalogEntry: + def test_create_entry(self): + entry = IntegrationCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=True, + description="Test catalog", + ) + assert entry.url == "https://example.com/catalog.json" + assert entry.name == "test" + assert entry.priority == 1 + assert entry.install_allowed is True + assert entry.description == "Test catalog" + + def test_default_description(self): + entry = IntegrationCatalogEntry( + url="https://example.com/catalog.json", + name="test", + priority=1, + install_allowed=False, + ) + assert entry.description == "" + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — URL validation +# --------------------------------------------------------------------------- + + +class TestCatalogURLValidation: + def test_https_allowed(self): + IntegrationCatalog._validate_catalog_url("https://example.com/catalog.json") + + def test_http_rejected(self): + with pytest.raises(IntegrationCatalogError, match="HTTPS"): + IntegrationCatalog._validate_catalog_url("http://example.com/catalog.json") + + def test_http_localhost_allowed(self): + IntegrationCatalog._validate_catalog_url("http://localhost:8080/catalog.json") + IntegrationCatalog._validate_catalog_url("http://127.0.0.1/catalog.json") + + def test_missing_host_rejected(self): + with pytest.raises(IntegrationCatalogError, match="valid URL"): + IntegrationCatalog._validate_catalog_url("https:///no-host") + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — active catalogs +# --------------------------------------------------------------------------- + + +class TestActiveCatalogs: + def test_defaults_when_no_config(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + active = cat.get_active_catalogs() + assert len(active) == 2 + assert active[0].name == "default" + assert active[1].name == "community" + + def test_env_var_override(self, tmp_path, monkeypatch): + (tmp_path / ".specify").mkdir() + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "https://custom.example.com/catalog.json", + ) + cat = IntegrationCatalog(tmp_path) + active = cat.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "custom" + + def test_project_config_overrides_defaults(self, tmp_path): + specify = tmp_path / ".specify" + specify.mkdir() + cfg = specify / "integration-catalogs.yml" + cfg.write_text(yaml.dump({ + "catalogs": [ + {"url": "https://my.example.com/cat.json", "name": "mine", "priority": 1, "install_allowed": True}, + ] + })) + cat = IntegrationCatalog(tmp_path) + active = cat.get_active_catalogs() + assert len(active) == 1 + assert active[0].name == "mine" + + def test_empty_config_raises(self, tmp_path): + specify = tmp_path / ".specify" + specify.mkdir() + cfg = specify / "integration-catalogs.yml" + cfg.write_text(yaml.dump({"catalogs": []})) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"): + cat.get_active_catalogs() + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — fetch & search (using monkeypatched urlopen responses) +# --------------------------------------------------------------------------- + + +class TestCatalogFetch: + """Tests that use a local HTTP server stub via monkeypatch.""" + + def _patch_urlopen(self, monkeypatch, catalog_data): + """Patch urllib.request.urlopen to return *catalog_data*.""" + + class FakeResponse: + def __init__(self, data, url=""): + self._data = json.dumps(data).encode() + self._url = url + + def read(self): + return self._data + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + def fake_urlopen(url, timeout=10): + return FakeResponse(catalog_data, url) + + import urllib.request + monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + + def test_fetch_and_search_all(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "acme-coder": { + "id": "acme-coder", + "name": "Acme Coder", + "version": "2.0.0", + "description": "Community integration for Acme Coder", + "author": "acme-org", + "tags": ["cli"], + }, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + results = cat.search() + assert len(results) >= 1 + ids = [r["id"] for r in results] + assert "acme-coder" in ids + + def test_search_by_tag(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "a": {"id": "a", "name": "A", "version": "1.0.0", "tags": ["cli"]}, + "b": {"id": "b", "name": "B", "version": "1.0.0", "tags": ["ide"]}, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + results = cat.search(tag="cli") + assert all("cli" in r.get("tags", []) for r in results) + + def test_search_by_query(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0", "description": "Anthropic", "tags": []}, + "gemini": {"id": "gemini", "name": "Gemini CLI", "version": "1.0.0", "description": "Google", "tags": []}, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + results = cat.search(query="claude") + assert len(results) == 1 + assert results[0]["id"] == "claude" + + def test_get_integration_info(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0"}, + }, + } + self._patch_urlopen(monkeypatch, catalog) + + info = cat.get_integration_info("claude") + assert info is not None + assert info["name"] == "Claude Code" + + assert cat.get_integration_info("nonexistent") is None + + def test_invalid_catalog_format(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + + self._patch_urlopen(monkeypatch, {"schema_version": "1.0"}) # missing "integrations" + + with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"): + cat.search() + + def test_clear_cache(self, tmp_path): + (tmp_path / ".specify").mkdir() + cat = IntegrationCatalog(tmp_path) + cat.cache_dir.mkdir(parents=True, exist_ok=True) + (cat.cache_dir / "catalog-abc123.json").write_text("{}") + cat.clear_cache() + assert not list(cat.cache_dir.glob("catalog-*.json")) + + +# --------------------------------------------------------------------------- +# IntegrationDescriptor (integration.yml) +# --------------------------------------------------------------------------- + +VALID_DESCRIPTOR = { + "schema_version": "1.0", + "integration": { + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Integration for My Agent", + "author": "my-org", + }, + "requires": { + "speckit_version": ">=0.6.0", + }, + "provides": { + "commands": [ + {"name": "speckit.specify", "file": "templates/speckit.specify.md"}, + ], + "scripts": ["update-context.sh"], + }, +} + + +class TestIntegrationDescriptor: + def _write(self, tmp_path, data): + p = tmp_path / "integration.yml" + p.write_text(yaml.dump(data)) + return p + + def test_valid_descriptor(self, tmp_path): + p = self._write(tmp_path, VALID_DESCRIPTOR) + desc = IntegrationDescriptor(p) + assert desc.id == "my-agent" + assert desc.name == "My Agent" + assert desc.version == "1.0.0" + assert desc.description == "Integration for My Agent" + assert desc.requires_speckit_version == ">=0.6.0" + assert len(desc.commands) == 1 + assert desc.scripts == ["update-context.sh"] + + def test_missing_schema_version(self, tmp_path): + data = {**VALID_DESCRIPTOR} + del data["schema_version"] + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"): + IntegrationDescriptor(p) + + def test_unsupported_schema_version(self, tmp_path): + data = {**VALID_DESCRIPTOR, "schema_version": "99.0"} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Unsupported schema version"): + IntegrationDescriptor(p) + + def test_missing_integration_id(self, tmp_path): + data = {**VALID_DESCRIPTOR, "integration": {"name": "X", "version": "1.0.0", "description": "Y"}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Missing integration.id"): + IntegrationDescriptor(p) + + def test_invalid_id_format(self, tmp_path): + integ = {**VALID_DESCRIPTOR["integration"], "id": "BAD_ID"} + data = {**VALID_DESCRIPTOR, "integration": integ} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Invalid integration ID"): + IntegrationDescriptor(p) + + def test_invalid_version(self, tmp_path): + integ = {**VALID_DESCRIPTOR["integration"], "version": "not-semver"} + data = {**VALID_DESCRIPTOR, "integration": integ} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="Invalid version"): + IntegrationDescriptor(p) + + def test_missing_speckit_version(self, tmp_path): + data = {**VALID_DESCRIPTOR, "requires": {}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="requires.speckit_version"): + IntegrationDescriptor(p) + + def test_no_commands_or_scripts(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="at least one command or script"): + IntegrationDescriptor(p) + + def test_command_missing_name(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"file": "x.md"}]}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="missing 'name' or 'file'"): + IntegrationDescriptor(p) + + def test_commands_not_a_list(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {"commands": "not-a-list", "scripts": ["a.sh"]}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="expected a list"): + IntegrationDescriptor(p) + + def test_scripts_not_a_list(self, tmp_path): + data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"name": "a", "file": "b"}], "scripts": "not-a-list"}} + p = self._write(tmp_path, data) + with pytest.raises(IntegrationDescriptorError, match="expected a list"): + IntegrationDescriptor(p) + + def test_file_not_found(self, tmp_path): + with pytest.raises(IntegrationDescriptorError, match="Descriptor not found"): + IntegrationDescriptor(tmp_path / "nonexistent.yml") + + def test_invalid_yaml(self, tmp_path): + p = tmp_path / "integration.yml" + p.write_text(": : :") + with pytest.raises(IntegrationDescriptorError, match="Invalid YAML"): + IntegrationDescriptor(p) + + def test_get_hash(self, tmp_path): + p = self._write(tmp_path, VALID_DESCRIPTOR) + desc = IntegrationDescriptor(p) + h = desc.get_hash() + assert h.startswith("sha256:") + + def test_tools_accessor(self, tmp_path): + data = {**VALID_DESCRIPTOR, "requires": { + "speckit_version": ">=0.6.0", + "tools": [{"name": "my-agent", "version": ">=1.0.0", "required": True}], + }} + p = self._write(tmp_path, data) + desc = IntegrationDescriptor(p) + assert len(desc.tools) == 1 + assert desc.tools[0]["name"] == "my-agent" + + +# --------------------------------------------------------------------------- +# CLI: integration list --catalog +# --------------------------------------------------------------------------- + + +class TestIntegrationListCatalog: + """Test ``specify integration list --catalog``.""" + + def _init_project(self, tmp_path): + """Create a minimal spec-kit project.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", + "--integration", "copilot", + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0, result.output + return project + + def test_list_catalog_flag(self, tmp_path, monkeypatch): + """--catalog should show catalog entries.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path) + + catalog = { + "schema_version": "1.0", + "updated_at": "2026-01-01T00:00:00Z", + "integrations": { + "test-agent": { + "id": "test-agent", + "name": "Test Agent", + "version": "1.0.0", + "description": "A test agent", + "tags": ["cli"], + }, + }, + } + + import urllib.request + + class FakeResponse: + def __init__(self, data, url=""): + self._data = json.dumps(data).encode() + self._url = url + def read(self): + return self._data + def geturl(self): + return self._url + def __enter__(self): + return self + def __exit__(self, *a): + pass + + monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url)) + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list", "--catalog"]) + finally: + os.chdir(old) + + assert result.exit_code == 0 + assert "test-agent" in result.output + assert "Test Agent" in result.output + + def test_list_without_catalog_still_works(self, tmp_path): + """Default list (no --catalog) works as before.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path) + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old) + + assert result.exit_code == 0 + assert "copilot" in result.output + assert "installed" in result.output + + +# --------------------------------------------------------------------------- +# CLI: integration upgrade +# --------------------------------------------------------------------------- + + +class TestIntegrationUpgrade: + """Test ``specify integration upgrade``.""" + + def _init_project(self, tmp_path, integration="copilot"): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", + "--integration", integration, + "--script", "sh", + "--no-git", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0, result.output + return project + + def test_upgrade_requires_speckit_project(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + old = os.getcwd() + try: + os.chdir(tmp_path) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "Not a spec-kit project" in result.output + + def test_upgrade_no_integration_installed(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "No integration is currently installed" in result.output + + def test_upgrade_succeeds(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "upgraded successfully" in result.output + + def test_upgrade_blocks_on_modified_files(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Modify a tracked file so the manifest hash won't match + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + assert manifest_path.exists(), "Manifest should exist after init" + manifest_data = json.loads(manifest_path.read_text()) + tracked_files = manifest_data.get("files", {}) + assert tracked_files, "Manifest should track at least one file" + first_rel = next(iter(tracked_files)) + target_file = project / first_rel + assert target_file.exists(), f"Tracked file {first_rel} should exist" + target_file.write_text("MODIFIED CONTENT\n") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "modified" in result.output.lower() + + def test_upgrade_force_overwrites_modified(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Modify a tracked file + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + manifest_data = json.loads(manifest_path.read_text()) + tracked_files = manifest_data.get("files", {}) + assert tracked_files, "Manifest should track at least one file" + first_rel = next(iter(tracked_files)) + target_file = project / first_rel + assert target_file.exists(), f"Tracked file {first_rel} should exist" + target_file.write_text("MODIFIED CONTENT\n") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "--force"], catch_exceptions=False) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "upgraded successfully" in result.output + + def test_upgrade_wrong_integration_key(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "claude"]) + finally: + os.chdir(old) + assert result.exit_code != 0 + assert "not the currently installed integration" in result.output + + def test_upgrade_no_manifest(self, tmp_path): + """Upgrade with missing manifest suggests fresh install.""" + from typer.testing import CliRunner + from specify_cli import app + runner = CliRunner() + project = self._init_project(tmp_path, "copilot") + + # Remove manifest + manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json" + if manifest_path.exists(): + manifest_path.unlink() + + old = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade"]) + finally: + os.chdir(old) + assert result.exit_code == 0 + assert "Nothing to upgrade" in result.output From c717cbb42d4f9dbc07c2afdf24b7126602916bad Mon Sep 17 00:00:00 2001 From: Hamilton Snow Date: Thu, 16 Apr 2026 23:31:56 +0800 Subject: [PATCH 058/184] feat: update memorylint and superpowers-bridge versions to 1.3.0 with new download URLs (#2240) --- extensions/catalog.community.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 9e06b8e255..17bf6f70e2 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1140,8 +1140,8 @@ "id": "memorylint", "description": "Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution.", "author": "RbBtSn0w", - "version": "1.0.0", - "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/archive/refs/tags/v1.0.0.zip", + "version": "1.3.0", + "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/memorylint-v1.3.0/memorylint.zip", "repository": "https://github.com/RbBtSn0w/spec-kit-extensions", "homepage": "https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint", "documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/memorylint/README.md", @@ -1165,7 +1165,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-09T00:00:00Z", - "updated_at": "2026-04-09T00:00:00Z" + "updated_at": "2026-04-16T13:10:26Z" }, "onboard": { "name": "Onboard", @@ -1893,8 +1893,8 @@ "id": "superb", "description": "Orchestrates obra/superpowers skills within the spec-kit SDD workflow. Thin bridge commands delegate to superpowers' authoritative SKILL.md files at runtime (with graceful fallback), while bridge-original commands provide spec-kit-native value. Eight commands cover the full lifecycle: intent clarification, TDD enforcement, task review, verification, critique, systematic debugging, branch completion, and review response. Hook-bound commands fire automatically; standalone commands are invoked when needed.", "author": "rbbtsn0w", - "version": "1.0.0", - "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.0.0/superpowers-bridge.zip", + "version": "1.3.0", + "download_url": "https://github.com/RbBtSn0w/spec-kit-extensions/releases/download/superpowers-bridge-v1.3.0/superpowers-bridge.zip", "repository": "https://github.com/RbBtSn0w/spec-kit-extensions", "homepage": "https://github.com/RbBtSn0w/spec-kit-extensions", "documentation": "https://github.com/RbBtSn0w/spec-kit-extensions/blob/main/superpowers-bridge/README.md", @@ -1929,7 +1929,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-30T00:00:00Z", - "updated_at": "2026-03-30T00:00:00Z" + "updated_at": "2026-04-16T14:08:23Z" }, "sync": { "name": "Spec Sync", From 530d1ce514be55d10f7d79a9b7772c7234dae134 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:29:46 -0500 Subject: [PATCH 059/184] docs: consolidate integration documentation into docs/integrations.md (#2241) - New docs/integrations.md: canonical reference for supported agents table (with keys), list/install/uninstall/switch/upgrade commands, file preservation behavior, and integration-specific options - README.md: replace inline agents table with summary + link to new page; normalize heading to 'Supported AI Coding Agent Integrations' - docs/toc.yml: add top-level 'Reference' section with Integrations page - docs/upgrade.md: fix broken cross-reference, update terminology - CONTRIBUTING.md: update anchor link to new heading --- CONTRIBUTING.md | 2 +- README.md | 41 +++------------ docs/integrations.md | 118 +++++++++++++++++++++++++++++++++++++++++++ docs/toc.yml | 6 +++ docs/upgrade.md | 4 +- 5 files changed, 134 insertions(+), 37 deletions(-) create mode 100644 docs/integrations.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ce19657ea..c44063b16f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ These are one time installations required to be able to test your changes locall 1. Install [Python 3.11+](https://www.python.org/downloads/) 1. Install [uv](https://docs.astral.sh/uv/) for package management 1. Install [Git](https://git-scm.com/downloads) -1. Have an [AI coding agent available](README.md#-supported-ai-agents) +1. Have an [AI coding agent available](README.md#-supported-ai-coding-agent-integrations)
💡 Hint if you are using VSCode or GitHub Codespaces as your IDE diff --git a/README.md b/README.md index b48c829491..101ffe5553 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - [🎨 Community Presets](#-community-presets) - [🚶 Community Walkthroughs](#-community-walkthroughs) - [🛠️ Community Friends](#️-community-friends) -- [🤖 Supported AI Agents](#-supported-ai-agents) +- [🤖 Supported AI Coding Agent Integrations](#-supported-ai-coding-agent-integrations) - [🔧 Specify CLI Reference](#-specify-cli-reference) - [🧩 Making Spec Kit Your Own: Extensions & Presets](#-making-spec-kit-your-own-extensions--presets) - [📚 Core Philosophy](#-core-philosophy) @@ -314,38 +314,11 @@ Community projects that extend, visualize, or build on Spec Kit: - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. -## 🤖 Supported AI Agents -| Agent | Support | Notes | -| ------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| [Qoder CLI](https://qoder.com/cli) | ✅ | | -| [Kiro CLI](https://kiro.dev/docs/cli/) | ✅ | Use `--ai kiro-cli` (alias: `--ai kiro`) | -| [Amp](https://ampcode.com/) | ✅ | | -| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | ✅ | | -| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | Installs skills in `.claude/skills`; invoke spec-kit as `/speckit-constitution`, `/speckit-plan`, etc. | -| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | ✅ | | -| [Codex CLI](https://github.com/openai/codex) | ✅ | Requires `--ai-skills`. Codex recommends [skills](https://developers.openai.com/codex/skills) and treats [custom prompts](https://developers.openai.com/codex/custom-prompts) as deprecated. Spec-kit installs Codex skills into `.agents/skills` and invokes them as `$speckit-`. | -| [Cursor](https://cursor.sh/) | ✅ | | -| [Forge](https://forgecode.dev/) | ✅ | CLI tool: `forge` | -| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | | -| [Goose](https://block.github.io/goose/) | ✅ | Uses YAML recipe format in `.goose/recipes/` with slash command support | -| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | | -| [IBM Bob](https://www.ibm.com/products/bob) | ✅ | IDE-based agent with slash command support | -| [Jules](https://jules.google.com/) | ✅ | | -| [Kilo Code](https://github.com/Kilo-Org/kilocode) | ✅ | | -| [opencode](https://opencode.ai/) | ✅ | | -| [Pi Coding Agent](https://pi.dev) | ✅ | 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) | -| [Qwen Code](https://github.com/QwenLM/qwen-code) | ✅ | | -| [Roo Code](https://roocode.com/) | ✅ | | -| [SHAI (OVHcloud)](https://github.com/ovh/shai) | ✅ | | -| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | ✅ | | -| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | ✅ | | -| [Kimi Code](https://code.kimi.com/) | ✅ | | -| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | ✅ | | -| [Windsurf](https://windsurf.com/) | ✅ | | -| [Junie](https://junie.jetbrains.com/) | ✅ | | -| [Antigravity (agy)](https://antigravity.google/) | ✅ | Requires `--ai-skills` | -| [Trae](https://www.trae.ai/) | ✅ | | -| Generic | ✅ | Bring your own agent — use `--ai generic --ai-commands-dir ` for unsupported agents | +## 🤖 Supported AI Coding Agent Integrations + +Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](docs/integrations.md) guide. + +Run `specify integration list` to see all available integrations in your installed version. ## Available Slash Commands @@ -611,7 +584,7 @@ Our research and experimentation focus on: ## 🔧 Prerequisites - **Linux/macOS/Windows** -- [Supported](#-supported-ai-agents) AI coding agent. +- [Supported](#-supported-ai-coding-agent-integrations) AI coding agent. - [uv](https://docs.astral.sh/uv/) for package management - [Python 3.11+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000000..35a0b09e19 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,118 @@ +# Supported AI Coding Agent Integrations + +The Specify CLI supports a wide range of AI coding agents. When you run `specify init`, the CLI sets up the appropriate command files, context rules, and directory structures for your chosen AI coding agent — so you can start using Spec-Driven Development immediately, regardless of which tool you prefer. + +## Supported AI Coding Agents + +| Agent | Key | Notes | +| ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [Amp](https://ampcode.com/) | `amp` | | +| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically | +| [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` | +| [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-` | +| [Cursor](https://cursor.sh/) | `cursor-agent` | | +| [Forge](https://forgecode.dev/) | `forge` | | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | +| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | +| [Goose](https://block.github.io/goose/) | `goose` | Uses YAML recipe format in `.goose/recipes/` | +| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent | +| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | | +| [Junie](https://junie.jetbrains.com/) | `junie` | | +| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | +| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | +| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Alias: `--integration kiro` | +| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | +| [opencode](https://opencode.ai/) | `opencode` | | +| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | +| [Qoder CLI](https://qoder.com/cli) | `qodercli` | | +| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | +| [Roo Code](https://roocode.com/) | `roo` | | +| [SHAI (OVHcloud)](https://github.com/ovh/shai) | `shai` | | +| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | | +| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically | +| [Windsurf](https://windsurf.com/) | `windsurf` | | +| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir "` for AI coding agents not listed above | + +## List Available Integrations + +```bash +specify integration list +``` + +Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based. + +## Install an Integration + +```bash +specify integration install +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | + +Installs the specified integration into the current project. Fails if another integration is already installed — use `switch` instead. If the installation fails partway through, it automatically rolls back to a clean state. + +> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init --integration ` instead. + +## Uninstall an Integration + +```bash +specify integration uninstall [] +``` + +| Option | Description | +| --------- | --------------------------------------------------- | +| `--force` | Remove files even if they have been modified | + +Uninstalls the current integration (or the specified one). Spec Kit tracks every file created during install along with a SHA-256 hash of the original content: + +- **Unmodified files** are removed automatically. +- **Modified files** (where you've made manual edits) are preserved so your customizations are not lost. +- Use `--force` to remove all integration files regardless of modifications. + +## Switch to a Different Integration + +```bash +specify integration switch +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--force` | Force removal of modified files during uninstall | +| `--integration-options` | Options for the target integration | + +Equivalent to running `uninstall` followed by `install` in a single step. + +## Upgrade an Integration + +```bash +specify integration upgrade [] +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--force` | Overwrite files even if they have been modified | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--integration-options` | Options for the integration | + +Reinstalls the current integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the currently installed integration; if a key is provided, it must match the installed one — otherwise the command fails and suggests using `switch` instead. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. + +## Integration-Specific Options + +Some integrations accept additional options via `--integration-options`: + +| Integration | Option | Description | +| ----------- | ------------------- | -------------------------------------------------------------- | +| `generic` | `--commands-dir` | Required. Directory for command files | +| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format | + +Example: + +```bash +specify integration install generic --integration-options="--commands-dir .myagent/cmds" +``` diff --git a/docs/toc.yml b/docs/toc.yml index 18650cb571..53d3c10884 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -12,6 +12,12 @@ - name: Upgrade href: upgrade.md +# Reference +- name: Reference + items: + - name: Integrations + href: integrations.md + # Development workflows - name: Development items: diff --git a/docs/upgrade.md b/docs/upgrade.md index aecbb7879b..e08c0b93ed 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -76,7 +76,7 @@ Run this inside your project directory: specify init --here --force --ai ``` -Replace `` with your AI assistant. Refer to this list of [Supported AI Agents](../README.md#-supported-ai-agents) +Replace `` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](integrations.md) **Example:** @@ -401,7 +401,7 @@ The `specify` CLI tool is used for: - **Upgrades:** `specify init --here --force` to update templates and commands - **Diagnostics:** `specify check` to verify tool installation -Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI assistant reads these command files directly—no need to run `specify` again. +Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again. **If your agent isn't recognizing slash commands:** From 076bb40f2ec618ab81439845b74ef4ea485d8a45 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:07:36 -0500 Subject: [PATCH 060/184] docs: add extensions reference page and integrations FAQ (#2242) - New docs/extensions.md: command reference for all 9 specify extension commands and 3 specify extension catalog commands, plus catalog resolution order, extension configuration, and FAQ - docs/integrations.md: add FAQ section covering single-integration limit, file preservation, key discovery, CLI vs IDE requirements, upgrade vs switch - docs/toc.yml: add Extensions under Reference section - README.md: update integration and extension links to published docs site --- README.md | 4 +- docs/extensions.md | 201 +++++++++++++++++++++++++++++++++++++++++++ docs/integrations.md | 22 +++++ docs/toc.yml | 2 + 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 docs/extensions.md diff --git a/README.md b/README.md index 101ffe5553..38d5fb3028 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ Community projects that extend, visualize, or build on Spec Kit: ## 🤖 Supported AI Coding Agent Integrations -Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](docs/integrations.md) guide. +Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/integrations.html) guide. Run `specify integration list` to see all available integrations in your installed version. @@ -510,7 +510,7 @@ specify extension add For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics. -See the [Extensions README](./extensions/README.md) for the full guide and how to build and publish your own. Browse the [community extensions](#-community-extensions) above for what's available. +See the [Extensions reference](https://github.github.io/spec-kit/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available. ### Presets — Customize Existing Workflows diff --git a/docs/extensions.md b/docs/extensions.md new file mode 100644 index 0000000000..923d0b9b82 --- /dev/null +++ b/docs/extensions.md @@ -0,0 +1,201 @@ +# Extensions + +Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. They introduce new commands and templates that go beyond the built-in Spec-Driven Development workflow. + +## Search Available Extensions + +```bash +specify extension search [query] +``` + +| Option | Description | +| ------------ | ------------------------------------ | +| `--tag` | Filter by tag | +| `--author` | Filter by author | +| `--verified` | Show only verified extensions | + +Searches all active catalogs for extensions matching the query. Without a query, lists all available extensions. + +## Install an Extension + +```bash +specify extension add +``` + +| Option | Description | +| --------------- | -------------------------------------------------------- | +| `--dev` | Install from a local directory (for development) | +| `--from ` | Install from a custom URL instead of the catalog | +| `--priority `| Resolution priority (default: 10; lower = higher precedence) | + +Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration. + +> **Note:** All extension commands require a project already initialized with `specify init`. + +## Remove an Extension + +```bash +specify extension remove +``` + +| Option | Description | +| --------------- | ---------------------------------------------- | +| `--keep-config` | Preserve configuration files during removal | +| `--force` | Skip confirmation prompt | + +Removes an installed extension. Configuration files are backed up by default; use `--keep-config` to leave them in place or `--force` to skip the confirmation. + +## List Installed Extensions + +```bash +specify extension list +``` + +| Option | Description | +| ------------- | -------------------------------------------------- | +| `--available` | Show available (uninstalled) extensions | +| `--all` | Show both installed and available extensions | + +Lists installed extensions with their status, version, and command counts. + +## Extension Info + +```bash +specify extension info +``` + +Shows detailed information about an installed or available extension, including its description, version, commands, and configuration. + +## Update Extensions + +```bash +specify extension update [] +``` + +Updates a specific extension, or all installed extensions if no name is given. + +## Enable / Disable an Extension + +```bash +specify extension enable +specify extension disable +``` + +Disable an extension without removing it. Disabled extensions are not loaded and their commands are not available. Re-enable with `enable`. + +## Set Extension Priority + +```bash +specify extension set-priority +``` + +Changes the resolution priority of an extension. When multiple extensions provide a command with the same name, the extension with the lowest priority number takes precedence. + +## Catalog Management + +Extension catalogs control where `search` and `add` look for extensions. Catalogs are checked in priority order (lower number = higher precedence). + +### List Catalogs + +```bash +specify extension catalog list +``` + +Shows all active catalogs in the stack with their priorities and install permissions. + +### Add a Catalog + +```bash +specify extension catalog add +``` + +| Option | Description | +| ------------------------------------ | -------------------------------------------------- | +| `--name ` | Required. Unique name for the catalog | +| `--priority ` | Priority (default: 10; lower = higher precedence) | +| `--install-allowed / --no-install-allowed` | Whether extensions can be installed from this catalog | +| `--description ` | Optional description | + +Adds a catalog to the project's `.specify/extension-catalogs.yml`. + +### Remove a Catalog + +```bash +specify extension catalog remove +``` + +Removes a catalog from the project configuration. + +### Catalog Resolution Order + +Catalogs are resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_CATALOG_URL` overrides all catalogs +2. **Project config** — `.specify/extension-catalogs.yml` +3. **User config** — `~/.specify/extension-catalogs.yml` +4. **Built-in defaults** — official catalog + community catalog + +Example `.specify/extension-catalogs.yml`: + +```yaml +catalogs: + - name: "my-org-catalog" + url: "https://example.com/catalog.json" + priority: 5 + install_allowed: true + description: "Our approved extensions" +``` + +## Extension Configuration + +Most extensions include configuration files in their install directory: + +```text +.specify/extensions// +├── -config.yml # Project config (version controlled) +├── -config.local.yml # Local overrides (gitignored) +└── -config.template.yml # Template reference +``` + +Configuration is merged in this order (highest priority last): + +1. **Extension defaults** (from `extension.yml`) +2. **Project config** (`-config.yml`) +3. **Local overrides** (`-config.local.yml`) +4. **Environment variables** (`SPECKIT__*`) + +To set up configuration for a newly installed extension, copy the template: + +```bash +cp .specify/extensions//-config.template.yml \ + .specify/extensions//-config.yml +``` + +## FAQ + +### Why can't I find an extension with `search`? + +Check the spelling of the extension name. The extension may not be published yet, or it may be in a catalog you haven't added. Use `specify extension catalog list` to see which catalogs are active. + +### Why doesn't the extension command appear in my AI coding agent? + +Verify the extension is installed and enabled with `specify extension list`. If it shows as installed, restart your AI coding agent — it may need to reload for it to take effect. + +### How do I set up extension configuration? + +Copy the config template that ships with the extension: + +```bash +cp .specify/extensions//-config.template.yml \ + .specify/extensions//-config.yml +``` + +See [Extension Configuration](#extension-configuration) for details on config layers and overrides. + +### How do I resolve an incompatible version error? + +Update Spec Kit to the version required by the extension. + +### Who maintains extensions? + +Most extensions are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support extension code. Review an extension's source code before installing and use at your own discretion. For issues with a specific extension, contact its author or file an issue on the extension's repository. diff --git a/docs/integrations.md b/docs/integrations.md index 35a0b09e19..dcb9a2b354 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -116,3 +116,25 @@ Example: ```bash specify integration install generic --integration-options="--commands-dir .myagent/cmds" ``` + +## FAQ + +### Can I use multiple integrations at the same time? + +No. Only one AI coding agent integration can be installed per project. Use `specify integration switch ` to change to a different AI coding agent. + +### What happens to my changes when I uninstall or switch? + +Files you've modified are preserved automatically. Only unmodified files (matching their original SHA-256 hash) are removed. Use `--force` to override this. + +### How do I know which key to use? + +Run `specify integration list` to see all available integrations with their keys, or check the [Supported AI Coding Agents](#supported-ai-coding-agents) table above. + +### Do I need the AI coding agent installed to use an integration? + +CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be installed. IDE-based integrations (like Windsurf, Cursor) work through the IDE itself. Some agents like GitHub Copilot support both IDE and CLI usage. `specify integration list` shows which type each integration is. + +### When should I use `upgrade` vs `switch`? + +Use `upgrade` when you've upgraded Spec Kit and want to refresh the same integration's templates. Use `switch` when you want to change to a different AI coding agent. diff --git a/docs/toc.yml b/docs/toc.yml index 53d3c10884..b65fcac9c8 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -17,6 +17,8 @@ items: - name: Integrations href: integrations.md + - name: Extensions + href: extensions.md # Development workflows - name: Development From 8d2797dc03943d103e00ebf09a170781edc206c8 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:41:07 -0500 Subject: [PATCH 061/184] docs: add presets reference page and rename pack_id to preset_id (#2243) - New docs/presets.md: command reference for all 9 specify preset commands and 3 specify preset catalog commands, file resolution stack with Mermaid diagrams, catalog resolution order, and FAQ - src/specify_cli/__init__.py: rename pack_id to preset_id across all preset CLI commands so --help shows PRESET_ID matching the docs - docs/toc.yml: add Presets under Reference section - README.md: update presets link to published docs site --- README.md | 2 +- docs/presets.md | 224 ++++++++++++++++++++++++++++++++++++ docs/toc.yml | 2 + src/specify_cli/__init__.py | 96 ++++++++-------- 4 files changed, 275 insertions(+), 49 deletions(-) create mode 100644 docs/presets.md diff --git a/README.md b/README.md index 38d5fb3028..b8be28b66c 100644 --- a/README.md +++ b/README.md @@ -526,7 +526,7 @@ specify preset add For example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering. -See the [Presets README](./presets/README.md) for the full guide, including resolution order, priority, and how to create your own. +See the [Presets reference](https://github.github.io/spec-kit/presets.html) for the full command guide, including resolution order and priority stacking. ### When to Use Which diff --git a/docs/presets.md b/docs/presets.md new file mode 100644 index 0000000000..4a613ffc00 --- /dev/null +++ b/docs/presets.md @@ -0,0 +1,224 @@ +# Presets + +Presets customize how Spec Kit works — overriding templates, commands, and terminology without changing any tooling. They let you enforce organizational standards, adapt the workflow to your methodology, or localize the entire experience. Multiple presets can be stacked with priority ordering. + +## Search Available Presets + +```bash +specify preset search [query] +``` + +| Option | Description | +| ---------- | -------------------- | +| `--tag` | Filter by tag | +| `--author` | Filter by author | + +Searches all active catalogs for presets matching the query. Without a query, lists all available presets. + +## Install a Preset + +```bash +specify preset add [] +``` + +| Option | Description | +| ---------------- | -------------------------------------------------------- | +| `--dev ` | Install from a local directory (for development) | +| `--from ` | Install from a custom URL instead of the catalog | +| `--priority ` | Resolution priority (default: 10; lower = higher precedence) | + +Installs a preset from the catalog, a URL, or a local directory. Preset commands are automatically registered with the currently installed AI coding agent integration. + +> **Note:** All preset commands require a project already initialized with `specify init`. + +## Remove a Preset + +```bash +specify preset remove +``` + +Removes an installed preset and cleans up its registered commands. + +## List Installed Presets + +```bash +specify preset list +``` + +Lists installed presets with their versions, descriptions, template counts, and current status. + +## Preset Info + +```bash +specify preset info +``` + +Shows detailed information about an installed or available preset, including its templates, metadata, and tags. + +## Resolve a File + +```bash +specify preset resolve +``` + +Shows which file will be used for a given name by tracing the full resolution stack. Useful for debugging when multiple presets provide the same file. + +## Enable / Disable a Preset + +```bash +specify preset enable +specify preset disable +``` + +Disable a preset without removing it. Disabled presets are skipped during file resolution but their commands remain registered. Re-enable with `enable`. + +## Set Preset Priority + +```bash +specify preset set-priority +``` + +Changes the resolution priority of an installed preset. Lower numbers take precedence. When multiple presets provide the same file, the one with the lowest priority number wins. + +## Catalog Management + +Preset catalogs control where `search` and `add` look for presets. Catalogs are checked in priority order (lower number = higher precedence). + +### List Catalogs + +```bash +specify preset catalog list +``` + +Shows all active catalogs with their priorities and install permissions. + +### Add a Catalog + +```bash +specify preset catalog add +``` + +| Option | Description | +| -------------------------------------------- | -------------------------------------------------- | +| `--name ` | Required. Unique name for the catalog | +| `--priority ` | Priority (default: 10; lower = higher precedence) | +| `--install-allowed / --no-install-allowed` | Whether presets can be installed from this catalog (default: discovery only) | +| `--description ` | Optional description | + +Adds a catalog to the project's `.specify/preset-catalogs.yml`. + +### Remove a Catalog + +```bash +specify preset catalog remove +``` + +Removes a catalog from the project configuration. + +### Catalog Resolution Order + +Catalogs are resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_PRESET_CATALOG_URL` overrides all catalogs +2. **Project config** — `.specify/preset-catalogs.yml` +3. **User config** — `~/.specify/preset-catalogs.yml` +4. **Built-in defaults** — official catalog + community catalog + +Example `.specify/preset-catalogs.yml`: + +```yaml +catalogs: + - name: "my-org-presets" + url: "https://example.com/preset-catalog.json" + priority: 5 + install_allowed: true + description: "Our approved presets" +``` + +## File Resolution + +Presets can provide command files, template files (like `plan-template.md`), and script files. These are resolved at runtime using a **replace** strategy — the first match in the priority stack wins and is used entirely. Each file is looked up independently, so different files can come from different layers. + +> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release. + +The resolution stack, from highest to lowest precedence: + +1. **Project-local overrides** — `.specify/templates/overrides/` +2. **Installed presets** — sorted by priority (lower = checked first) +3. **Installed extensions** — sorted by priority +4. **Spec Kit core** — `.specify/templates/` + +Commands are registered at install time (not resolved through the stack at runtime). + +### Resolution Stack + +```mermaid +flowchart TB + subgraph stack [" "] + direction TB + A["⬆ Highest precedence

1. Project-local overrides
.specify/templates/overrides/"] + B["2. Presets — by priority
.specify/presets/‹id›/"] + C["3. Extensions — by priority
.specify/extensions/‹id›/"] + D["4. Spec Kit core
.specify/templates/

⬇ Lowest precedence"] + end + + A --> B --> C --> D + + style A fill:#4a9,color:#fff + style B fill:#49a,color:#fff + style C fill:#a94,color:#fff + style D fill:#999,color:#fff +``` + +Within each layer, files are organized by type: + +| Type | Subdirectory | Override path | +| --------- | -------------- | ------------------------------------------ | +| Templates | `templates/` | `.specify/templates/overrides/` | +| Commands | `commands/` | `.specify/templates/overrides/` | +| Scripts | `scripts/` | `.specify/templates/overrides/scripts/` | + +### Resolution in Action + +```mermaid +flowchart TB + A["File requested:
plan-template.md"] --> B{"Project-local override?"} + B -- Found --> Z["✓ Use this file"] + B -- Not found --> C{"Preset: compliance
(priority 5)"} + C -- Found --> Z + C -- Not found --> D{"Preset: team-workflow
(priority 10)"} + D -- Found --> Z + D -- Not found --> E{"Extension files?"} + E -- Found --> Z + E -- Not found --> F["Spec Kit core"] + F --> Z +``` + +### Example + +```bash +specify preset add compliance --priority 5 +specify preset add team-workflow --priority 10 +``` + +For any file that both provide, `compliance` wins (priority 5 < 10). For files only one provides, that one is used. For files neither provides, the core default is used. + +## FAQ + +### Can I use multiple presets at the same time? + +Yes. Presets stack by priority — each file is resolved independently from the highest-priority source that provides it. Use `specify preset set-priority` to control the order. + +### How do I see which file is actually being used? + +Run `specify preset resolve ` to trace the resolution stack and see which file wins. + +### What's the difference between disabling and removing a preset? + +**Disabling** (`specify preset disable`) keeps the preset installed but excludes its files from the resolution stack. Commands the preset registered remain available in your AI coding agent. This is useful for temporarily testing behavior without a preset, or comparing output with and without it. Re-enable anytime with `specify preset enable`. + +**Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry. + +### Who maintains presets? + +Most presets are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support preset code. Review a preset's source code before installing and use at your own discretion. For issues with a specific preset, contact its author or file an issue on the preset's repository. diff --git a/docs/toc.yml b/docs/toc.yml index b65fcac9c8..3f53367075 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -19,6 +19,8 @@ href: integrations.md - name: Extensions href: extensions.md + - name: Presets + href: presets.md # Development workflows - name: Development diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 9f895cb3b9..5c079ece89 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2376,7 +2376,7 @@ def preset_list(): @preset_app.command("add") def preset_add( - pack_id: str = typer.Argument(None, help="Preset ID to install from catalog"), + preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"), from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"), dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"), priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), @@ -2444,19 +2444,19 @@ def preset_add( console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") - elif pack_id: + elif preset_id: # Try bundled preset first, then catalog - bundled_path = _locate_bundled_preset(pack_id) + bundled_path = _locate_bundled_preset(preset_id) if bundled_path: - console.print(f"Installing bundled preset [cyan]{pack_id}[/cyan]...") + console.print(f"Installing bundled preset [cyan]{preset_id}[/cyan]...") manifest = manager.install_from_directory(bundled_path, speckit_version, priority) console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") else: catalog = PresetCatalog(project_root) - pack_info = catalog.get_pack_info(pack_id) + pack_info = catalog.get_pack_info(preset_id) if not pack_info: - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in catalog") raise typer.Exit(1) # Bundled presets should have been caught above; if we reach @@ -2464,7 +2464,7 @@ def preset_add( if pack_info.get("bundled") and not pack_info.get("download_url"): from .extensions import REINSTALL_COMMAND console.print( - f"[red]Error:[/red] Preset '{pack_id}' is bundled with spec-kit " + f"[red]Error:[/red] Preset '{preset_id}' is bundled with spec-kit " f"but could not be found in the installed package." ) console.print( @@ -2476,14 +2476,14 @@ def preset_add( if not pack_info.get("_install_allowed", True): catalog_name = pack_info.get("_catalog_name", "unknown") - console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") + console.print(f"[red]Error:[/red] Preset '{preset_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).") console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.") raise typer.Exit(1) - console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...") + console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...") try: - zip_path = catalog.download_pack(pack_id) + zip_path = catalog.download_pack(preset_id) manifest = manager.install_from_zip(zip_path, speckit_version, priority) console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})") finally: @@ -2506,7 +2506,7 @@ def preset_add( @preset_app.command("remove") def preset_remove( - pack_id: str = typer.Argument(..., help="Preset ID to remove"), + preset_id: str = typer.Argument(..., help="Preset ID to remove"), ): """Remove an installed preset.""" from .presets import PresetManager @@ -2521,14 +2521,14 @@ def preset_remove( manager = PresetManager(project_root) - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) - if manager.remove(pack_id): - console.print(f"[green]✓[/green] Preset '{pack_id}' removed successfully") + if manager.remove(preset_id): + console.print(f"[green]✓[/green] Preset '{preset_id}' removed successfully") else: - console.print(f"[red]Error:[/red] Failed to remove preset '{pack_id}'") + console.print(f"[red]Error:[/red] Failed to remove preset '{preset_id}'") raise typer.Exit(1) @@ -2599,7 +2599,7 @@ def preset_resolve( @preset_app.command("info") def preset_info( - pack_id: str = typer.Argument(..., help="Preset ID to get info about"), + preset_id: str = typer.Argument(..., help="Preset ID to get info about"), ): """Show detailed information about a preset.""" from .extensions import normalize_priority @@ -2615,7 +2615,7 @@ def preset_info( # Check if installed locally first manager = PresetManager(project_root) - local_pack = manager.get_pack(pack_id) + local_pack = manager.get_pack(preset_id) if local_pack: console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n") @@ -2637,7 +2637,7 @@ def preset_info( console.print(f" License: {license_val}") console.print("\n [green]Status: installed[/green]") # Get priority from registry - pack_metadata = manager.registry.get(pack_id) + pack_metadata = manager.registry.get(preset_id) priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None) console.print(f" [dim]Priority:[/dim] {priority}") console.print() @@ -2646,15 +2646,15 @@ def preset_info( # Fall back to catalog catalog = PresetCatalog(project_root) try: - pack_info = catalog.get_pack_info(pack_id) + pack_info = catalog.get_pack_info(preset_id) except PresetError: pack_info = None if not pack_info: - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found (not installed and not in catalog)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") raise typer.Exit(1) - console.print(f"\n[bold cyan]Preset: {pack_info.get('name', pack_id)}[/bold cyan]\n") + console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n") console.print(f" ID: {pack_info['id']}") console.print(f" Version: {pack_info.get('version', '?')}") console.print(f" Description: {pack_info.get('description', '')}") @@ -2667,13 +2667,13 @@ def preset_info( if pack_info.get("license"): console.print(f" License: {pack_info['license']}") console.print("\n [yellow]Status: not installed[/yellow]") - console.print(f" Install with: [cyan]specify preset add {pack_id}[/cyan]") + console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]") console.print() @preset_app.command("set-priority") def preset_set_priority( - pack_id: str = typer.Argument(help="Preset ID"), + preset_id: str = typer.Argument(help="Preset ID"), priority: int = typer.Argument(help="New priority (lower = higher precedence)"), ): """Set the resolution priority of an installed preset.""" @@ -2696,14 +2696,14 @@ def preset_set_priority( manager = PresetManager(project_root) # Check if preset is installed - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) # Get current metadata - metadata = manager.registry.get(pack_id) + metadata = manager.registry.get(preset_id) if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) from .extensions import normalize_priority @@ -2711,21 +2711,21 @@ def preset_set_priority( # Only skip if the stored value is already a valid int equal to requested priority # This ensures corrupted values (e.g., "high") get repaired even when setting to default (10) if isinstance(raw_priority, int) and raw_priority == priority: - console.print(f"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]") + console.print(f"[yellow]Preset '{preset_id}' already has priority {priority}[/yellow]") raise typer.Exit(0) old_priority = normalize_priority(raw_priority) # Update priority - manager.registry.update(pack_id, {"priority": priority}) + manager.registry.update(preset_id, {"priority": priority}) - console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority} → {priority}") + console.print(f"[green]✓[/green] Preset '{preset_id}' priority changed: {old_priority} → {priority}") console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]") @preset_app.command("enable") def preset_enable( - pack_id: str = typer.Argument(help="Preset ID to enable"), + preset_id: str = typer.Argument(help="Preset ID to enable"), ): """Enable a disabled preset.""" from .presets import PresetManager @@ -2742,31 +2742,31 @@ def preset_enable( manager = PresetManager(project_root) # Check if preset is installed - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) # Get current metadata - metadata = manager.registry.get(pack_id) + metadata = manager.registry.get(preset_id) if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) if metadata.get("enabled", True): - console.print(f"[yellow]Preset '{pack_id}' is already enabled[/yellow]") + console.print(f"[yellow]Preset '{preset_id}' is already enabled[/yellow]") raise typer.Exit(0) # Enable the preset - manager.registry.update(pack_id, {"enabled": True}) + manager.registry.update(preset_id, {"enabled": True}) - console.print(f"[green]✓[/green] Preset '{pack_id}' enabled") + console.print(f"[green]✓[/green] Preset '{preset_id}' enabled") console.print("\nTemplates from this preset will now be included in resolution.") console.print("[dim]Note: Previously registered commands/skills remain active.[/dim]") @preset_app.command("disable") def preset_disable( - pack_id: str = typer.Argument(help="Preset ID to disable"), + preset_id: str = typer.Argument(help="Preset ID to disable"), ): """Disable a preset without removing it.""" from .presets import PresetManager @@ -2783,27 +2783,27 @@ def preset_disable( manager = PresetManager(project_root) # Check if preset is installed - if not manager.registry.is_installed(pack_id): - console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed") + if not manager.registry.is_installed(preset_id): + console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed") raise typer.Exit(1) # Get current metadata - metadata = manager.registry.get(pack_id) + metadata = manager.registry.get(preset_id) if metadata is None or not isinstance(metadata, dict): - console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)") + console.print(f"[red]Error:[/red] Preset '{preset_id}' not found in registry (corrupted state)") raise typer.Exit(1) if not metadata.get("enabled", True): - console.print(f"[yellow]Preset '{pack_id}' is already disabled[/yellow]") + console.print(f"[yellow]Preset '{preset_id}' is already disabled[/yellow]") raise typer.Exit(0) # Disable the preset - manager.registry.update(pack_id, {"enabled": False}) + manager.registry.update(preset_id, {"enabled": False}) - console.print(f"[green]✓[/green] Preset '{pack_id}' disabled") + console.print(f"[green]✓[/green] Preset '{preset_id}' disabled") console.print("\nTemplates from this preset will be skipped during resolution.") console.print("[dim]Note: Previously registered commands/skills remain active until preset removal.[/dim]") - console.print(f"To re-enable: specify preset enable {pack_id}") + console.print(f"To re-enable: specify preset enable {preset_id}") # ===== Preset Catalog Commands ===== From 02a1d610dffec7541bdd251f321b913ba01f6c27 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:34:08 -0500 Subject: [PATCH 062/184] docs: add workflows reference, reorganize into docs/reference/, and add --version flag (#2244) * docs: add workflows reference, reorganize into docs/reference/, and add --version flag - Move integrations.md, extensions.md, presets.md into docs/reference/ - New docs/reference/workflows.md: command reference for all workflow commands, built-in SDD Cycle workflow with Mermaid diagram, step types, expressions, input types, state/resume, and FAQ - Rename workflow input feature_name to spec with prompt 'Describe what you want to build' to match speckit.specify command terminology - Add --version / -V flag to root specify command with tests - Update docs/toc.yml, README.md links, and docs/upgrade.md cross-reference to use reference/ paths - Add workflow command to README CLI reference table * docs: update speckit_version requirement to >=0.7.2 in workflow example --- README.md | 7 +- docs/{ => reference}/extensions.md | 0 docs/{ => reference}/integrations.md | 0 docs/{ => reference}/presets.md | 0 docs/reference/workflows.md | 289 +++++++++++++++++++++++ docs/toc.yml | 8 +- docs/upgrade.md | 2 +- src/specify_cli/__init__.py | 10 +- src/specify_cli/workflows/expressions.py | 2 +- tests/test_cli_version.py | 35 +++ tests/test_workflows.py | 8 +- workflows/PUBLISHING.md | 8 +- workflows/README.md | 20 +- workflows/speckit/workflow.yml | 14 +- 14 files changed, 369 insertions(+), 34 deletions(-) rename docs/{ => reference}/extensions.md (100%) rename docs/{ => reference}/integrations.md (100%) rename docs/{ => reference}/presets.md (100%) create mode 100644 docs/reference/workflows.md create mode 100644 tests/test_cli_version.py diff --git a/README.md b/README.md index b8be28b66c..a12c6cbb75 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ Community projects that extend, visualize, or build on Spec Kit: ## 🤖 Supported AI Coding Agent Integrations -Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/integrations.html) guide. +Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/reference/integrations.html) guide. Run `specify integration list` to see all available integrations in your installed version. @@ -367,6 +367,7 @@ and supports the following commands: | `extension` | Manage extensions | | `preset` | Manage presets | | `integration` | Manage integrations | +| `workflow` | Run, manage, and search workflows. See the [Workflows reference](https://github.github.io/spec-kit/reference/workflows.html) | ### `specify init` Arguments & Options @@ -510,7 +511,7 @@ specify extension add For example, extensions could add Jira integration, post-implementation code review, V-Model test traceability, or project health diagnostics. -See the [Extensions reference](https://github.github.io/spec-kit/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available. +See the [Extensions reference](https://github.github.io/spec-kit/reference/extensions.html) for the full command guide. Browse the [community extensions](#-community-extensions) above for what's available. ### Presets — Customize Existing Workflows @@ -526,7 +527,7 @@ specify preset add For example, presets could restructure spec templates to require regulatory traceability, adapt the workflow to fit the methodology you use (e.g., Agile, Kanban, Waterfall, jobs-to-be-done, or domain-driven design), add mandatory security review gates to plans, enforce test-first task ordering, or localize the entire workflow to a different language. The [pirate-speak demo](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo) shows just how deep the customization can go. Multiple presets can be stacked with priority ordering. -See the [Presets reference](https://github.github.io/spec-kit/presets.html) for the full command guide, including resolution order and priority stacking. +See the [Presets reference](https://github.github.io/spec-kit/reference/presets.html) for the full command guide, including resolution order and priority stacking. ### When to Use Which diff --git a/docs/extensions.md b/docs/reference/extensions.md similarity index 100% rename from docs/extensions.md rename to docs/reference/extensions.md diff --git a/docs/integrations.md b/docs/reference/integrations.md similarity index 100% rename from docs/integrations.md rename to docs/reference/integrations.md diff --git a/docs/presets.md b/docs/reference/presets.md similarity index 100% rename from docs/presets.md rename to docs/reference/presets.md diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md new file mode 100644 index 0000000000..e7e921e1e9 --- /dev/null +++ b/docs/reference/workflows.md @@ -0,0 +1,289 @@ +# Workflows + +Workflows automate multi-step Spec-Driven Development processes — chaining commands, prompts, shell steps, and human checkpoints into repeatable sequences. They support conditional logic, loops, fan-out/fan-in, and can be paused and resumed from the exact point of interruption. + +## Run a Workflow + +```bash +specify workflow run +``` + +| Option | Description | +| ------------------- | -------------------------------------------------------- | +| `-i` / `--input` | Pass input values as `key=value` (repeatable) | + +Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via `--input` or will be prompted interactively. + +Example: + +```bash +specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full +``` + +> **Note:** All workflow commands require a project already initialized with `specify init`. + +## Resume a Workflow + +```bash +specify workflow resume +``` + +Resumes a paused or failed workflow run from the exact step where it stopped. Useful after responding to a gate step or fixing an issue that caused a failure. + +## Workflow Status + +```bash +specify workflow status [] +``` + +Shows the status of a specific run, or lists all runs if no ID is given. Run states: `created`, `running`, `completed`, `paused`, `failed`, `aborted`. + +## List Installed Workflows + +```bash +specify workflow list +``` + +Lists workflows installed in the current project. + +## Install a Workflow + +```bash +specify workflow add +``` + +Installs a workflow from the catalog, a URL (HTTPS required), or a local file path. + +## Remove a Workflow + +```bash +specify workflow remove +``` + +Removes an installed workflow from the project. + +## Search Available Workflows + +```bash +specify workflow search [query] +``` + +| Option | Description | +| ------- | --------------- | +| `--tag` | Filter by tag | + +Searches all active catalogs for workflows matching the query. + +## Workflow Info + +```bash +specify workflow info +``` + +Shows detailed information about a workflow, including its steps, inputs, and requirements. + +## Catalog Management + +Workflow catalogs control where `search` and `add` look for workflows. Catalogs are checked in priority order. + +### List Catalogs + +```bash +specify workflow catalog list +``` + +Shows all active catalog sources. + +### Add a Catalog + +```bash +specify workflow catalog add +``` + +| Option | Description | +| --------------- | -------------------------------- | +| `--name ` | Optional name for the catalog | + +Adds a custom catalog URL to the project's `.specify/workflow-catalogs.yml`. + +### Remove a Catalog + +```bash +specify workflow catalog remove +``` + +Removes a catalog by its index in the catalog list. + +### Catalog Resolution Order + +Catalogs are resolved in this order (first match wins): + +1. **Environment variable** — `SPECKIT_WORKFLOW_CATALOG_URL` overrides all catalogs +2. **Project config** — `.specify/workflow-catalogs.yml` +3. **User config** — `~/.specify/workflow-catalogs.yml` +4. **Built-in defaults** — official catalog + community catalog + +## Workflow Definition + +Workflows are defined in YAML files. Here is the built-in **Full SDD Cycle** workflow that ships with Spec Kit: + +```yaml +schema_version: "1.0" +workflow: + id: "speckit" + name: "Full SDD Cycle" + version: "1.0.0" + author: "GitHub" + description: "Runs specify → plan → tasks → implement with review gates" + +requires: + speckit_version: ">=0.7.2" + integrations: + any: ["copilot", "claude", "gemini"] + +inputs: + spec: + type: string + required: true + prompt: "Describe what you want to build" + integration: + type: string + default: "copilot" + prompt: "Integration to use (e.g. claude, copilot, gemini)" + scope: + type: string + default: "full" + enum: ["full", "backend-only", "frontend-only"] + +steps: + - id: specify + command: speckit.specify + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-spec + type: gate + message: "Review the generated spec before planning." + options: [approve, reject] + on_reject: abort + + - id: plan + command: speckit.plan + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: review-plan + type: gate + message: "Review the plan before generating tasks." + options: [approve, reject] + on_reject: abort + + - id: tasks + command: speckit.tasks + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" + + - id: implement + command: speckit.implement + integration: "{{ inputs.integration }}" + input: + args: "{{ inputs.spec }}" +``` + +This produces the following execution flow: + +```mermaid +flowchart TB + A["specify
(command)"] --> B{"review-spec
(gate)"} + B -- approve --> C["plan
(command)"] + B -- reject --> X1["⏹ Abort"] + C --> D{"review-plan
(gate)"} + D -- approve --> E["tasks
(command)"] + D -- reject --> X2["⏹ Abort"] + E --> F["implement
(command)"] + + style A fill:#49a,color:#fff + style B fill:#a94,color:#fff + style C fill:#49a,color:#fff + style D fill:#a94,color:#fff + style E fill:#49a,color:#fff + style F fill:#49a,color:#fff + style X1 fill:#999,color:#fff + style X2 fill:#999,color:#fff +``` + +Run it with: + +```bash +specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" +``` + +## Step Types + +| Type | Purpose | +| ------------ | ------------------------------------------------ | +| `command` | Invoke a Spec Kit command (e.g., `speckit.plan`) | +| `prompt` | Send an arbitrary prompt to the AI coding agent | +| `shell` | Execute a shell command and capture output | +| `gate` | Pause for human approval before continuing | +| `if` | Conditional branching (then/else) | +| `switch` | Multi-branch dispatch on an expression | +| `while` | Loop while a condition is true | +| `do-while` | Execute at least once, then loop on condition | +| `fan-out` | Dispatch a step for each item in a list | +| `fan-in` | Aggregate results from a fan-out step | + +## Expressions + +Steps can reference inputs and previous step outputs using `{{ expression }}` syntax: + +| Namespace | Description | +| ------------------------------ | ------------------------------------ | +| `inputs.spec` | Workflow input values | +| `steps.specify.output.file` | Output from a previous step | +| `item` | Current item in a fan-out iteration | + +Available filters: `default`, `join`, `contains`, `map`. + +Example: + +```yaml +condition: "{{ steps.test.output.exit_code == 0 }}" +args: "{{ inputs.spec }}" +message: "{{ status | default('pending') }}" +``` + +## Input Types + +| Type | Coercion | +| --------- | ------------------------------------------------- | +| `string` | Pass-through | +| `number` | `"42"` → `42`, `"3.14"` → `3.14` | +| `boolean` | `"true"` / `"1"` / `"yes"` → `True` | + +## State and Resume + +Each workflow run persists its state at `.specify/workflows/runs//`: + +- `state.json` — current run state and step progress +- `inputs.json` — resolved input values +- `log.jsonl` — step-by-step execution log + +This enables `specify workflow resume` to continue from the exact step where a run was paused (e.g., at a gate) or failed. + +## FAQ + +### What happens when a workflow hits a gate step? + +The workflow pauses and waits for human input. Run `specify workflow resume ` after reviewing to continue. + +### Can I run the same workflow multiple times? + +Yes. Each run gets a unique ID and its own state directory. Use `specify workflow status` to see all runs. + +### Who maintains workflows? + +Most workflows are independently created and maintained by their respective authors. The Spec Kit maintainers do not review, audit, endorse, or support workflow code. Review a workflow's source before installing and use at your own discretion. diff --git a/docs/toc.yml b/docs/toc.yml index 3f53367075..5666cbb230 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -16,11 +16,13 @@ - name: Reference items: - name: Integrations - href: integrations.md + href: reference/integrations.md - name: Extensions - href: extensions.md + href: reference/extensions.md - name: Presets - href: presets.md + href: reference/presets.md + - name: Workflows + href: reference/workflows.md # Development workflows - name: Development diff --git a/docs/upgrade.md b/docs/upgrade.md index e08c0b93ed..020360d222 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -76,7 +76,7 @@ Run this inside your project directory: specify init --here --force --ai ``` -Replace `` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](integrations.md) +Replace `` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md) **Example:** diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 5c079ece89..0608e7a8ac 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -351,8 +351,16 @@ def show_banner(): console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) console.print() +def _version_callback(value: bool): + if value: + console.print(f"specify {get_speckit_version()}") + raise typer.Exit() + @app.callback() -def callback(ctx: typer.Context): +def callback( + ctx: typer.Context, + version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."), +): """Show banner when no subcommand is provided.""" if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv: show_banner() diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py index 3a2d3fbf2a..eb39a31e79 100644 --- a/src/specify_cli/workflows/expressions.py +++ b/src/specify_cli/workflows/expressions.py @@ -255,7 +255,7 @@ def evaluate_expression(template: str, context: Any) -> Any: ---------- template: The template string (e.g., ``"{{ steps.plan.output.task_count }}"`` - or ``"Processed {{ inputs.feature_name }}"``. + or ``"Processed {{ inputs.spec }}"``. context: A ``StepContext`` or compatible object. diff --git a/tests/test_cli_version.py b/tests/test_cli_version.py new file mode 100644 index 0000000000..80555d8b77 --- /dev/null +++ b/tests/test_cli_version.py @@ -0,0 +1,35 @@ +"""Tests for the --version CLI flag.""" + +from unittest.mock import patch + +from typer.testing import CliRunner + +from specify_cli import app + + +runner = CliRunner() + + +class TestVersionFlag: + """Test --version / -V flag on the root command.""" + + def test_version_long_flag(self): + """specify --version prints version and exits 0.""" + with patch("specify_cli.get_speckit_version", return_value="1.2.3"): + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "specify 1.2.3" in result.output + + def test_version_short_flag(self): + """specify -V prints version and exits 0.""" + with patch("specify_cli.get_speckit_version", return_value="1.2.3"): + result = runner.invoke(app, ["-V"]) + assert result.exit_code == 0 + assert "specify 1.2.3" in result.output + + def test_version_flag_takes_precedence_over_subcommand(self): + """--version should work even when a subcommand follows.""" + with patch("specify_cli.get_speckit_version", return_value="0.7.2"): + result = runner.invoke(app, ["--version", "init"]) + assert result.exit_code == 0 + assert "specify 0.7.2" in result.output diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 96893249e2..c972945d04 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -54,7 +54,7 @@ def sample_workflow_yaml(): description: "A test workflow" inputs: - feature_name: + spec: type: string required: true scope: @@ -65,7 +65,7 @@ def sample_workflow_yaml(): - id: step-one command: speckit.specify input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: step-two command: speckit.plan @@ -1152,8 +1152,8 @@ def test_inputs_parsed(self, sample_workflow_yaml): from specify_cli.workflows.engine import WorkflowDefinition definition = WorkflowDefinition.from_string(sample_workflow_yaml) - assert "feature_name" in definition.inputs - assert definition.inputs["feature_name"]["required"] is True + assert "spec" in definition.inputs + assert definition.inputs["spec"]["required"] is True assert definition.inputs["scope"]["default"] == "full" diff --git a/workflows/PUBLISHING.md b/workflows/PUBLISHING.md index 857aaf7d11..ce0d251826 100644 --- a/workflows/PUBLISHING.md +++ b/workflows/PUBLISHING.md @@ -62,10 +62,10 @@ requires: any: ["claude", "gemini"] # At least one required inputs: - feature_name: + spec: type: string required: true - prompt: "Feature name" + prompt: "Describe what you want to build" scope: type: string default: "full" @@ -75,7 +75,7 @@ steps: - id: specify command: speckit.specify input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: review type: gate @@ -99,7 +99,7 @@ steps: ```bash # Run with required inputs -specify workflow run ./workflow.yml --input feature_name="user-auth" +specify workflow run ./workflow.yml --input spec="Build a user authentication system with OAuth support" # Check validation specify workflow info ./workflow.yml diff --git a/workflows/README.md b/workflows/README.md index 3ece00b6b0..31f736ff76 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -11,7 +11,7 @@ steps: - id: specify command: speckit.specify input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: review type: gate @@ -35,10 +35,10 @@ specify workflow search specify workflow add speckit # Or run directly from a local YAML file -specify workflow run ./workflow.yml --input feature_name="user-auth" +specify workflow run ./workflow.yml --input spec="Build a user authentication system with OAuth support" # Run an installed workflow with inputs -specify workflow run speckit --input feature_name="user-auth" +specify workflow run speckit --input spec="Build a user authentication system with OAuth support" # Check run status specify workflow status @@ -59,20 +59,20 @@ specify workflow remove speckit ```bash specify workflow add speckit -specify workflow run speckit --input feature_name="user-auth" +specify workflow run speckit --input spec="Build a user authentication system with OAuth support" ``` ### From a Local YAML File ```bash -specify workflow run ./my-workflow.yml --input feature_name="user-auth" +specify workflow run ./my-workflow.yml --input spec="Build a user authentication system with OAuth support" ``` ### Multiple Inputs ```bash specify workflow run speckit \ - --input feature_name="user-auth" \ + --input spec="Build a user authentication system with OAuth support" \ --input scope="backend-only" ``` @@ -88,7 +88,7 @@ Invoke an installed Spec Kit command by name via the integration CLI: - id: specify command: speckit.specify input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" integration: claude # Optional: override workflow default model: "claude-sonnet-4-20250514" # Optional: override model ``` @@ -225,7 +225,7 @@ Workflow definitions use `{{ expression }}` syntax for dynamic values: ```yaml # Access inputs -args: "{{ inputs.feature_name }}" +args: "{{ inputs.spec }}" # Access previous step outputs args: "{{ steps.specify.output.file }}" @@ -245,10 +245,10 @@ Workflow inputs are type-checked and coerced from CLI string values: ```yaml inputs: - feature_name: + spec: type: string required: true - prompt: "Feature name" + prompt: "Describe what you want to build" task_count: type: number default: 5 diff --git a/workflows/speckit/workflow.yml b/workflows/speckit/workflow.yml index a440c5c507..bf18451029 100644 --- a/workflows/speckit/workflow.yml +++ b/workflows/speckit/workflow.yml @@ -7,15 +7,15 @@ workflow: description: "Runs specify → plan → tasks → implement with review gates" requires: - speckit_version: ">=0.6.1" + speckit_version: ">=0.7.2" integrations: any: ["copilot", "claude", "gemini"] inputs: - feature_name: + spec: type: string required: true - prompt: "Feature name" + prompt: "Describe what you want to build" integration: type: string default: "copilot" @@ -30,7 +30,7 @@ steps: command: speckit.specify integration: "{{ inputs.integration }}" input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: review-spec type: gate @@ -42,7 +42,7 @@ steps: command: speckit.plan integration: "{{ inputs.integration }}" input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: review-plan type: gate @@ -54,10 +54,10 @@ steps: command: speckit.tasks integration: "{{ inputs.integration }}" input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" - id: implement command: speckit.implement integration: "{{ inputs.integration }}" input: - args: "{{ inputs.feature_name }}" + args: "{{ inputs.spec }}" From 697daec7333874788c3791812624c5043394888d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:54:25 -0500 Subject: [PATCH 063/184] docs: add core commands reference and simplify README CLI section (#2245) * docs: add core commands reference and simplify README CLI section - New docs/reference/core.md: reference for init (active options only, copilot as main example), check, and version commands - docs/toc.yml: add Core Commands under Reference - README.md: replace verbose CLI Reference section (init options table, 30+ per-agent examples, deprecated flags, env vars) with links to reference docs; use copilot as main example throughout * docs: add CLI reference overview page - New docs/reference/overview.md: explains each CLI surface area (core, integrations, extensions, presets, workflows) with key commands and links to detailed reference pages - docs/toc.yml: add Overview as first item under Reference - README.md: simplify CLI Reference to single link to overview page * docs: remove command references from overview, keep paragraphs only --- README.md | 151 +++---------------------------------- docs/reference/core.md | 79 +++++++++++++++++++ docs/reference/overview.md | 33 ++++++++ docs/toc.yml | 4 + 4 files changed, 127 insertions(+), 140 deletions(-) create mode 100644 docs/reference/core.md create mode 100644 docs/reference/overview.md diff --git a/README.md b/README.md index a12c6cbb75..119f0c8a0f 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,9 @@ And use the tool directly: specify init # Or initialize in existing project -specify init . --ai claude +specify init . --ai copilot # or -specify init --here --ai claude +specify init --here --ai copilot # Check installed tools specify check @@ -100,9 +100,9 @@ Run directly without installing: uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init # Or initialize in existing project -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai copilot # or -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot ``` **Benefits of persistent installation:** @@ -349,136 +349,7 @@ Additional commands for enhanced quality and validation: ## 🔧 Specify CLI Reference -The `specify` tool is invoked as - -```text -specify [SUBCOMMAND] [OPTIONS] -``` - -and supports the following commands: - -### Commands - -| Command | Description | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `init` | Initialize a new Specify project from the latest template. | -| `check` | Check for installed tools: `git` plus all CLI-based agents configured in `AGENT_CONFIG` (for example: `claude`, `gemini`, `code`/`code-insiders`, `cursor-agent`, `windsurf`, `junie`, `qwen`, `opencode`, `codex`, `kiro-cli`, `shai`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, etc.) | -| `version` | Show the currently installed Spec Kit version. | -| `extension` | Manage extensions | -| `preset` | Manage presets | -| `integration` | Manage integrations | -| `workflow` | Run, manage, and search workflows. See the [Workflows reference](https://github.github.io/spec-kit/reference/workflows.html) | - -### `specify init` Arguments & Options - -```bash -specify init [PROJECT_NAME] -``` - -| Argument/Option | Type | Description | -| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `` | Argument | Name for your new project directory (optional if using `--here`, or use `.` for current directory) | -| `--ai` | Option | AI assistant to use (see `AGENT_CONFIG` for the full, up-to-date list). Common options include: `claude`, `gemini`, `copilot`, `cursor-agent`, `qwen`, `opencode`, `codex`, `windsurf`, `junie`, `kilocode`, `auggie`, `roo`, `codebuddy`, `amp`, `shai`, `kiro-cli` (`kiro` alias), `agy`, `bob`, `qodercli`, `vibe`, `kimi`, `iflow`, `pi`, `forge`, or `generic` (requires `--ai-commands-dir`) | -| `--ai-commands-dir` | Option | Directory for agent command files (required with `--ai generic`, e.g. `.myagent/commands/`) | -| `--script` | Option | Script variant to use: `sh` (bash/zsh) or `ps` (PowerShell) | -| `--ignore-agent-tools` | Flag | Skip checks for AI agent tools like Claude Code | -| `--no-git` | Flag | Skip git repository initialization | -| `--here` | Flag | Initialize project in the current directory instead of creating a new one | -| `--force` | Flag | Force merge/overwrite when initializing in current directory (skip confirmation) | -| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | -| `--debug` | Flag | Enable detailed debug output for troubleshooting | -| `--github-token` | Option | GitHub token for API requests (or set GH_TOKEN/GITHUB_TOKEN env variable) | -| `--ai-skills` | Flag | Install Prompt.MD templates as agent skills in agent-specific `skills/` directory (requires `--ai`). Extension commands are also auto-registered as skills when extensions are added later. | -| `--branch-numbering` | Option | Branch numbering strategy: `sequential` (default — `001`, `002`, `003`, …, `1000`, … — expands beyond 3 digits automatically) or `timestamp` (`YYYYMMDD-HHMMSS`). Timestamp mode is useful for distributed teams to avoid numbering conflicts | - -### Examples - -```bash -# Basic project initialization -specify init my-project - -# Initialize with specific AI assistant -specify init my-project --ai claude - -# Initialize with Cursor support -specify init my-project --ai cursor-agent - -# Initialize with Qoder support -specify init my-project --ai qodercli - -# Initialize with Windsurf support -specify init my-project --ai windsurf - -# Initialize with Kiro CLI support -specify init my-project --ai kiro-cli - -# Initialize with Amp support -specify init my-project --ai amp - -# Initialize with SHAI support -specify init my-project --ai shai - -# Initialize with Mistral Vibe support -specify init my-project --ai vibe - -# Initialize with IBM Bob support -specify init my-project --ai bob - -# Initialize with Pi Coding Agent support -specify init my-project --ai pi - -# Initialize with Codex CLI support -specify init my-project --ai codex --ai-skills - -# Initialize with Antigravity support -specify init my-project --ai agy --ai-skills - -# Initialize with Forge support -specify init my-project --ai forge - -# Initialize with an unsupported agent (generic / bring your own agent) -specify init my-project --ai generic --ai-commands-dir .myagent/commands/ - -# Initialize with PowerShell scripts (Windows/cross-platform) -specify init my-project --ai copilot --script ps - -# Initialize in current directory -specify init . --ai copilot -# or use the --here flag -specify init --here --ai copilot - -# Force merge into current (non-empty) directory without confirmation -specify init . --force --ai copilot -# or -specify init --here --force --ai copilot - -# Skip git initialization -specify init my-project --ai gemini --no-git - -# Enable debug output for troubleshooting -specify init my-project --ai claude --debug - -# Use GitHub token for API requests (helpful for corporate environments) -specify init my-project --ai claude --github-token ghp_your_token_here - -# Claude Code installs skills with the project by default -specify init my-project --ai claude - -# Initialize in current directory with agent skills -specify init --here --ai gemini --ai-skills - -# Use timestamp-based branch numbering (useful for distributed teams) -specify init my-project --ai claude --branch-numbering timestamp - -# Check system requirements -specify check -``` - -### Environment Variables - -| Variable | Description | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches.
\*\*Must be set in the context of the agent you're working with prior to using `/speckit.plan` or follow-up commands. | +For full command details, options, and examples, see the [CLI Reference](https://github.github.io/spec-kit/reference/overview.html). ## 🧩 Making Spec Kit Your Own: Extensions & Presets @@ -627,29 +498,29 @@ specify init --here --force You will be prompted to select the AI agent you are using. You can also proactively specify it directly in the terminal: ```bash -specify init --ai claude +specify init --ai copilot specify init --ai gemini specify init --ai copilot # Or in current directory: -specify init . --ai claude +specify init . --ai copilot specify init . --ai codex --ai-skills # or use --here flag -specify init --here --ai claude +specify init --here --ai copilot specify init --here --ai codex --ai-skills # Force merge into a non-empty current directory -specify init . --force --ai claude +specify init . --force --ai copilot # or -specify init --here --force --ai claude +specify init --here --force --ai copilot ``` The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash -specify init --ai claude --ignore-agent-tools +specify init --ai copilot --ignore-agent-tools ``` ### **STEP 1:** Establish project principles diff --git a/docs/reference/core.md b/docs/reference/core.md new file mode 100644 index 0000000000..fdab05a02b --- /dev/null +++ b/docs/reference/core.md @@ -0,0 +1,79 @@ +# Core Commands + +The core `specify` commands handle project initialization, system checks, and version information. + +## Initialize a Project + +```bash +specify init [] +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------ | +| `--integration ` | AI coding agent integration to use (e.g. `copilot`, `claude`, `gemini`). See the [Integrations reference](integrations.md) for all available keys | +| `--integration-options` | Options for the integration (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | +| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--here` | Initialize in the current directory instead of creating a new one | +| `--force` | Force merge/overwrite when initializing in an existing directory | +| `--no-git` | Skip git repository initialization | +| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools | +| `--preset ` | Install a preset during initialization | +| `--branch-numbering` | Branch numbering strategy: `sequential` (default) or `timestamp` | + +Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files. + +Use `` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation. + +### Examples + +```bash +# Create a new project with an integration +specify init my-project --integration copilot + +# Initialize in the current directory +specify init --here --integration copilot + +# Force merge into a non-empty directory +specify init --here --force --integration copilot + +# Use PowerShell scripts (Windows/cross-platform) +specify init my-project --integration copilot --script ps + +# Skip git initialization +specify init my-project --integration copilot --no-git + +# Install a preset during initialization +specify init my-project --integration copilot --preset compliance + +# Use timestamp-based branch numbering (useful for distributed teams) +specify init my-project --integration copilot --branch-numbering timestamp +``` + +### Environment Variables + +| Variable | Description | +| ----------------- | ------------------------------------------------------------------------ | +| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. | + +## Check Installed Tools + +```bash +specify check +``` + +Checks that required tools are available on your system: `git` and any CLI-based AI coding agents. IDE-based agents are skipped since they don't require a CLI tool. + +## Version Information + +```bash +specify version +``` + +Displays the Spec Kit CLI version, Python version, platform, and architecture. + +A quick version check is also available via: + +```bash +specify --version +specify -V +``` diff --git a/docs/reference/overview.md b/docs/reference/overview.md new file mode 100644 index 0000000000..10fcdc3bca --- /dev/null +++ b/docs/reference/overview.md @@ -0,0 +1,33 @@ +# CLI Reference + +The Specify CLI (`specify`) manages the full lifecycle of Spec-Driven Development — from project initialization to workflow automation. + +## Core Commands + +The foundational commands for creating and managing Spec Kit projects. Initialize a new project with the necessary directory structure, templates, and scripts. Verify that your system has the required tools installed. Check version and system information. + +[Core Commands reference →](core.md) + +## Integrations + +Integrations connect Spec Kit to your AI coding agent. Each integration sets up the appropriate command files, context rules, and directory structures for a specific agent. Only one integration is active per project at a time, and you can switch between them at any point. + +[Integrations reference →](integrations.md) + +## Extensions + +Extensions add new capabilities to Spec Kit — domain-specific commands, external tool integrations, quality gates, and more. They are discovered through catalogs and can be installed, updated, enabled, disabled, or removed independently. Multiple extensions can coexist in a single project. + +[Extensions reference →](extensions.md) + +## Presets + +Presets customize how Spec Kit works — overriding command files, template files, and script files without changing any tooling. They let you enforce organizational standards, adapt the workflow to your methodology, or localize the entire experience. Multiple presets can be stacked with priority ordering to layer customizations. + +[Presets reference →](presets.md) + +## Workflows + +Workflows automate multi-step Spec-Driven Development processes into repeatable sequences. They chain commands, prompts, shell steps, and human checkpoints together, with support for conditional logic, loops, fan-out/fan-in, and the ability to pause and resume from the exact point of interruption. + +[Workflows reference →](workflows.md) diff --git a/docs/toc.yml b/docs/toc.yml index 5666cbb230..70ae77de39 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -15,6 +15,10 @@ # Reference - name: Reference items: + - name: Overview + href: reference/overview.md + - name: Core Commands + href: reference/core.md - name: Integrations href: reference/integrations.md - name: Extensions From 26fab003ee1b37d48138c52b52b43c190cd787b5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:03:59 -0500 Subject: [PATCH 064/184] chore: release 0.7.2, begin 0.7.3.dev0 development (#2247) * chore: bump version to 0.7.2 * chore: begin 0.7.3.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c362aee259..a8d2202f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.7.2] - 2026-04-16 + +### Changed + +- docs: add core commands reference and simplify README CLI section (#2245) +- docs: add workflows reference, reorganize into docs/reference/, and add --version flag (#2244) +- docs: add presets reference page and rename pack_id to preset_id (#2243) +- docs: add extensions reference page and integrations FAQ (#2242) +- docs: consolidate integration documentation into docs/integrations.md (#2241) +- feat: update memorylint and superpowers-bridge versions to 1.3.0 with new download URLs (#2240) +- feat: Integration catalog — discovery, versioning, and community distribution (#2130) +- Add Catalog CI extension to community catalog (#2239) +- Added issues extension (#2194) +- chore: release 0.7.1, begin 0.7.2.dev0 development (#2235) + ## [0.7.1] - 2026-04-15 ### Changed diff --git a/pyproject.toml b/pyproject.toml index d7208fa389..fc4b306351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.2.dev0" +version = "0.7.3.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 669e253809ec3f5df29eefbaab9fdb9a4f8adfb9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:12:25 -0500 Subject: [PATCH 065/184] fix: add reference/*.md to docfx content glob (#2248) Without this, the reference subdirectory pages are not included in the docfx build and return 404 on the published site. --- docs/docfx.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/docfx.json b/docs/docfx.json index dca3f0f578..c34fe84b84 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -4,7 +4,8 @@ { "files": [ "*.md", - "toc.yml" + "toc.yml", + "reference/*.md" ] }, { From ca382992f7deae803620b2e841b5e64d7f5586df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 06:46:47 -0500 Subject: [PATCH 066/184] chore(deps): bump actions/upload-pages-artifact from 3 to 5 (#2251) Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 5. - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5) --- updated-dependencies: - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5f1f97dc77..6fe87ddce2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -51,7 +51,7 @@ jobs: uses: actions/configure-pages@v6 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: 'docs/_site' From 2c11525be5a2eac24f8b6da5b58eede8e0504c61 Mon Sep 17 00:00:00 2001 From: adaumann <94932945+adaumann@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:04:43 +0200 Subject: [PATCH 067/184] preset: Update preset-fiction-book-writing to community catalog -> v1.5.0 (#2256) * Update preset-fiction-book-writing to community catalog - Preset ID: fiction-book-writing - Version: 1.5.0 - Author: Andreas Daumann - Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. V1.5.0: Support interactive, audiobooks, series, workflow corrections * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- presets/catalog.community.json | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 119f0c8a0f..756aa092aa 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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) | | 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) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | -| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes, author voice sample or humanized AI prose. | 21 templates, 17 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations): features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose with 5 prose profiles. Supports interactive elements like brainstorming, interview, roleplay. | 21 templates, 26 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | 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) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index bc105e7486..b25a5ee2ba 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -108,11 +108,11 @@ "fiction-book-writing": { "name": "Fiction Book Writing", "id": "fiction-book-writing", - "version": "1.3.0", + "version": "1.5.0", "description": "Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc.", "author": "Andreas Daumann", "repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing", - "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.3.0.zip", + "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.5.0.zip", "homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing", "documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md", "license": "MIT", @@ -121,23 +121,24 @@ }, "provides": { "templates": 21, - "commands": 17, - "scripts": 1 + "commands": 26 }, "tags": [ "writing", "novel", - "book", "fiction", "storytelling", "creative-writing", "kdp", - "single-pov", "multi-pov", - "export" + "export", + "book", + "brainstorming", + "roleplay", + "audiobook" ], "created_at": "2026-04-09T08:00:00Z", - "updated_at": "2026-04-09T08:00:00Z" + "updated_at": "2026-04-16T08:00:00Z" }, "multi-repo-branching": { "name": "Multi-Repo Branching", From dedcae7cd87282d64b3c07b7a03f70f42dad566c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A4=80=ED=98=B8?= <101695482+chordpli@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:14:24 +0900 Subject: [PATCH 068/184] feat: register Blueprint in community catalog (#2252) * feat: add Blueprint extension to community catalog - Extension ID: blueprint - Version: 1.0.0 - Author: chordpli - Repository: https://github.com/chordpli/spec-kit-blueprint * fix: update catalog root updated_at to current timestamp * fix: update hooks count to 1 (removed before_implement) * fix: use canonical /speckit.implement command name in description --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 756aa092aa..2a5d3a51d4 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ The following community-contributed extensions are available in [`catalog.commun | Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | +| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) | | Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) | | Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) | | Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 17bf6f70e2..f732f745b1 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-16T18:00:00Z", + "updated_at": "2026-04-17T01:05:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -170,6 +170,38 @@ "created_at": "2026-03-03T00:00:00Z", "updated_at": "2026-03-03T00:00:00Z" }, + "blueprint": { + "name": "Blueprint", + "id": "blueprint", + "description": "Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs", + "author": "chordpli", + "version": "1.0.0", + "download_url": "https://github.com/chordpli/spec-kit-blueprint/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/chordpli/spec-kit-blueprint", + "homepage": "https://github.com/chordpli/spec-kit-blueprint", + "documentation": "https://github.com/chordpli/spec-kit-blueprint/blob/main/README.md", + "changelog": "https://github.com/chordpli/spec-kit-blueprint/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "blueprint", + "pre-implementation", + "review", + "scaffolding", + "code-literacy" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-17T00:00:00Z", + "updated_at": "2026-04-17T00:00:00Z" + }, "branch-convention": { "name": "Branch Convention", "id": "branch-convention", From ba9a8b8e59cbb549f8304959294f87129f49f5e1 Mon Sep 17 00:00:00 2001 From: saram ali <140950904+SARAMALI15792@users.noreply.github.com> Date: Fri, 17 Apr 2026 19:27:45 +0500 Subject: [PATCH 069/184] fix: suppress CRLF warnings in auto-commit.ps1 (#2258) * fix: suppress CRLF warnings in auto-commit.ps1 (#2253) Replace 2> with 2>&1 redirection and assignment to properly suppress stderr output including CRLF warnings on Windows. Exit code logic preserved for change detection. Fixes #2253 * fix: use SilentlyContinue for CRLF stderr handling, add tests The 2>&1 approach still raises terminating errors under $ErrorActionPreference='Stop'. Instead, temporarily set SilentlyContinue around all native git calls that may emit CRLF warnings to stderr (rev-parse, diff, ls-files, add, commit). Adds 5 pytest tests (TestAutoCommitPowerShellCRLF) that set core.autocrlf=true with LF-ending files. On Windows runners this triggers actual CRLF warnings; on other platforms the tests pass trivially. Fixes #2253 * refactor: address Copilot review feedback - Use 'Continue' instead of 'SilentlyContinue' so error output is still captured in $out for diagnostics on real git failures. - Wrap all three EAP save/restore blocks in try/finally to guarantee restoration even on unexpected exceptions. - Fix CRLF test to commit a tracked LF file first, then modify it, so git diff --quiet HEAD actually inspects the tracked change and triggers the CRLF warning on Windows. * test: assert CRLF warning fires on Windows On Windows, probe git diff stderr before running the script to verify the test setup actually produces the expected CRLF warning. This makes the regression test deterministic on the Windows runner. On non-Windows the probe is skipped (warnings don't fire there). --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- .../git/scripts/powershell/auto-commit.ps1 | 30 +++- tests/extensions/git/test_git_extension.py | 151 ++++++++++++++++++ 2 files changed, 176 insertions(+), 5 deletions(-) diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1 index 2229ed2b8d..4a8b0e00cd 100644 --- a/extensions/git/scripts/powershell/auto-commit.ps1 +++ b/extensions/git/scripts/powershell/auto-commit.ps1 @@ -36,10 +36,17 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) { exit 0 } +# Temporarily relax ErrorActionPreference so git stderr warnings +# (e.g. CRLF notices on Windows) do not become terminating errors. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' try { git rev-parse --is-inside-work-tree 2>$null | Out-Null - if ($LASTEXITCODE -ne 0) { throw "not a repo" } -} catch { + $isRepo = $LASTEXITCODE -eq 0 +} finally { + $ErrorActionPreference = $savedEAP +} +if (-not $isRepo) { Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit" exit 0 } @@ -117,9 +124,16 @@ if (-not $enabled) { } # Check if there are changes to commit -$diffHead = git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE -$diffCached = git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE -$untracked = git ls-files --others --exclude-standard 2>$null +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE + git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE + $untracked = git ls-files --others --exclude-standard 2>$null +} finally { + $ErrorActionPreference = $savedEAP +} if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) { Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray @@ -136,6 +150,10 @@ if (-not $commitMsg) { } # Stage and commit +# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate, +# while still allowing redirected error output to be captured for diagnostics. +$savedEAP = $ErrorActionPreference +$ErrorActionPreference = 'Continue' try { $out = git add . 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" } @@ -144,6 +162,8 @@ try { } catch { Write-Warning "[specify] Error: $_" exit 1 +} finally { + $ErrorActionPreference = $savedEAP } Write-Host "[OK] Changes committed $phase $commandName" diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 30694fc9d8..c4f986d177 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -14,6 +14,7 @@ import re import shutil import subprocess +import sys from pathlib import Path import pytest @@ -585,6 +586,156 @@ def test_success_message_no_unicode_checkmark(self, tmp_path: Path): assert "\u2713" not in result.stdout, "Must not use Unicode checkmark" +# ── auto-commit.ps1 CRLF warning tests (issue #2253) ──────────────────────── + + +@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available") +class TestAutoCommitPowerShellCRLF: + """Tests for CRLF warning handling in auto-commit.ps1 (issue #2253). + + On Windows, git emits CRLF warnings to stderr when core.autocrlf=true + and files use LF line endings. PowerShell's $ErrorActionPreference='Stop' + converts stderr output into terminating errors, crashing the script. + + These tests use core.autocrlf=true + explicit LF-ending files. On Windows + the CRLF warnings fire and exercise the fix; on other platforms the tests + still run (they just won't produce stderr warnings, so they pass trivially). + """ + + # -- positive tests (fix works) ---------------------------------------- + + def test_commit_succeeds_with_autocrlf(self, tmp_path: Path): + """auto-commit.ps1 creates a commit when core.autocrlf=true (CRLF + warnings on stderr must not crash the script).""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + ' message: "crlf commit"\n' + )) + # Create and commit a tracked LF-ending file first so the script's + # `git diff --quiet HEAD` checks inspect a tracked modification. + tracked = project / "crlf-test.txt" + tracked.write_bytes(b"line one\nline two\nline three\n") + subprocess.run(["git", "add", "crlf-test.txt"], cwd=project, check=True) + subprocess.run( + ["git", "commit", "-m", "seed tracked file"], + cwd=project, check=True, env={**os.environ, **_GIT_ENV}, + ) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + # Modify the tracked file with explicit LF endings to trigger the + # CRLF warning during diff/status checks on Windows. + tracked.write_bytes(b"line one\nline two changed\nline three\n") + + # On Windows, verify the test setup actually produces a CRLF warning. + if sys.platform == "win32": + probe = subprocess.run( + ["git", "diff", "--quiet", "HEAD"], + cwd=project, capture_output=True, text=True, + ) + assert "LF will be replaced by CRLF" in probe.stderr, ( + "Expected CRLF warning from git on Windows; test setup may be wrong" + ) + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + + assert result.returncode == 0, ( + f"Script crashed (likely CRLF stderr); stderr:\n{result.stderr}" + ) + assert "[OK] Changes committed" in result.stdout + + log = subprocess.run( + ["git", "log", "--oneline", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "crlf commit" in log.stdout + + def test_custom_message_not_corrupted_by_crlf(self, tmp_path: Path): + """Commit message is the configured value, not a CRLF warning.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_plan:\n" + " enabled: true\n" + ' message: "[Project] Plan done"\n' + )) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + (project / "plan.txt").write_bytes(b"plan\ncontent\n") + + result = _run_pwsh("auto-commit.ps1", project, "after_plan") + assert result.returncode == 0 + + log = subprocess.run( + ["git", "log", "--format=%s", "-1"], + cwd=project, capture_output=True, text=True, + ) + assert "[Project] Plan done" in log.stdout.strip() + + def test_no_changes_still_skips_with_autocrlf(self, tmp_path: Path): + """Script correctly detects 'no changes' even with core.autocrlf=true.""" + project = _setup_project(tmp_path) + _write_config(project, ( + "auto_commit:\n" + " default: false\n" + " after_specify:\n" + " enabled: true\n" + )) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + # Stage and commit everything so the working tree is clean. + subprocess.run(["git", "add", "."], cwd=project, check=True, + env={**os.environ, **_GIT_ENV}) + subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, + check=True, env={**os.environ, **_GIT_ENV}) + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + assert "[OK]" not in result.stdout, "Should not have committed anything" + + # -- negative tests (real errors still surface) ------------------------ + + def test_not_a_repo_still_detected_with_autocrlf(self, tmp_path: Path): + """Script still exits gracefully when not in a git repo, even though + ErrorActionPreference is relaxed around the rev-parse call.""" + project = _setup_project(tmp_path, git=False) + _write_config(project, "auto_commit:\n default: true\n") + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + combined = result.stdout + result.stderr + assert "not a git repository" in combined.lower() or "warning" in combined.lower() + + def test_missing_config_still_exits_cleanly_with_autocrlf(self, tmp_path: Path): + """Script exits 0 when git-config.yml is absent (no over-suppression).""" + project = _setup_project(tmp_path) + subprocess.run( + ["git", "config", "core.autocrlf", "true"], + cwd=project, check=True, + ) + config = project / ".specify" / "extensions" / "git" / "git-config.yml" + config.unlink(missing_ok=True) + + result = _run_pwsh("auto-commit.ps1", project, "after_specify") + assert result.returncode == 0 + # Should not have committed anything — config file missing means disabled. + log = subprocess.run( + ["git", "log", "--oneline"], + cwd=project, capture_output=True, text=True, + ) + assert log.stdout.strip().count("\n") == 0 # only the seed commit + + # ── git-common.sh Tests ────────────────────────────────────────────────────── From 3b82e0bcdd34e63049d46aab75e22912201359bb Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Fri, 17 Apr 2026 16:54:08 +0200 Subject: [PATCH 070/184] docs: add Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace to README (#2250) * docs: add Claude Code / Copilot plugin installation option Add Option 4 to README installation section documenting plugin-based installation via Claude Code and Copilot CLI marketplace commands * docs(readme): move cc-spec-kit plugin to Community Friends Relocate the cc-spec-kit plugin reference to the Community Friends --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2a5d3a51d4..9050ab2d01 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,8 @@ Community projects that extend, visualize, or build on Spec Kit: - **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. +- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace. + ## 🤖 Supported AI Coding Agent Integrations Spec Kit works with 30+ AI coding agents — both CLI tools and IDE-based assistants. See the full list with notes and usage details in the [Supported AI Coding Agent Integrations](https://github.github.io/spec-kit/reference/integrations.html) guide. From 13b614e9d5e5591e365570cccb3798a7f87f7360 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 17 Apr 2026 21:40:19 +0500 Subject: [PATCH 071/184] Add Spec Scope extension to community catalog (#2172) - Adds scope entry to catalog.community.json (between review and security-review) - Adds Spec Scope row to community extensions table in README.md (between Spec Refine and Spec Sync) - Bumps top-level updated_at to 2026-04-16T19:00:00Z --- README.md | 1 + extensions/catalog.community.json | 35 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9050ab2d01..5c2f2fd96a 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | | Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | +| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index f732f745b1..8761682c17 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-17T01:05:00Z", + "updated_at": "2026-04-17T02:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1657,6 +1657,39 @@ "created_at": "2026-03-06T00:00:00Z", "updated_at": "2026-04-09T00:00:00Z" }, + "scope": { + "name": "Spec Scope", + "id": "scope", + "description": "Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-scope-/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-scope-", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-scope-", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-scope-/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-scope-/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 1 + }, + "tags": [ + "estimation", + "scope", + "effort", + "planning", + "project-management", + "tracking" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-17T02:00:00Z", + "updated_at": "2026-04-17T02:00:00Z" + }, "security-review": { "name": "Security Review", "id": "security-review", From 518dc9ddadf5f76d8e1818bdcd0278dd0f351233 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:33:20 -0500 Subject: [PATCH 072/184] Add Community Friends page to docs site (#2261) Move the Community Friends section from the main README into a dedicated docs page at docs/community/friends.md, following the same structure as the Reference section. - New: docs/community/friends.md with content from README - Updated: docs/toc.yml with Community section and Friends entry - Updated: docs/docfx.json to include community/*.md in content glob - Updated: README.md to link to the new docs page instead of inline list --- README.md | 13 +------------ docs/community/friends.md | 14 ++++++++++++++ docs/docfx.json | 1 + docs/toc.yml | 6 ++++++ 4 files changed, 22 insertions(+), 12 deletions(-) create mode 100644 docs/community/friends.md diff --git a/README.md b/README.md index 5c2f2fd96a..9f196bc123 100644 --- a/README.md +++ b/README.md @@ -305,18 +305,7 @@ See Spec-Driven Development in action across different scenarios with these comm ## 🛠️ Community Friends -> [!NOTE] -> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion. - -Community projects that extend, visualize, or build on Spec Kit: - -- **[cc-spex](https://github.com/rhuss/cc-spex)** - A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. - -- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. - -- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. - -- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace. +Community projects that extend, visualize, or build on Spec Kit. See the full list on the [Community Friends](https://github.github.io/spec-kit/community/friends.html) page. ## 🤖 Supported AI Coding Agent Integrations diff --git a/docs/community/friends.md b/docs/community/friends.md new file mode 100644 index 0000000000..31c6318699 --- /dev/null +++ b/docs/community/friends.md @@ -0,0 +1,14 @@ +# Community Friends + +> [!NOTE] +> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion. + +Community projects that extend, visualize, or build on Spec Kit: + +- **[cc-spex](https://github.com/rhuss/cc-spex)** — A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams. + +- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH. + +- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates. + +- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace. diff --git a/docs/docfx.json b/docs/docfx.json index c34fe84b84..3fb9c32ebb 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -5,6 +5,7 @@ "files": [ "*.md", "toc.yml", + "community/*.md", "reference/*.md" ] }, diff --git a/docs/toc.yml b/docs/toc.yml index 70ae77de39..add814d757 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -33,3 +33,9 @@ items: - name: Local Development href: local-development.md + +# Community +- name: Community + items: + - name: Friends + href: community/friends.md From fc3d1244c07c612e146cfad65d9e36542ee14175 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:57:51 -0500 Subject: [PATCH 073/184] fix: replace shell-based context updates with marker-based upsert (#2259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace shell-based context updates with marker-based upsert Replace ~3500 lines of bash/PowerShell agent context update scripts with a Python-based approach using markers. IntegrationBase now manages the agent context file directly: - upsert_context_section(): creates or updates the marked section at init/install/switch time with a directive to read the current plan - remove_context_section(): removes the section at uninstall, deleting the file only if it becomes empty - __CONTEXT_FILE__ placeholder in command templates is resolved per integration so the plan command references the correct agent file - context_file is persisted in init-options.json for extension access The plan command template instructs the LLM to update the plan reference between the markers in the agent context file. Removed: - scripts/bash/update-agent-context.sh (857 lines) - scripts/powershell/update-agent-context.ps1 (515 lines) - 56 integration wrapper scripts (update-context.sh/.ps1) - templates/agent-file-template.md - agent_scripts frontmatter key and {AGENT_SCRIPT} replacement logic - update-context reference from integration.json - tests/test_cursor_frontmatter.py (tested deleted scripts) Added: - upsert/remove context section methods on IntegrationBase - __CONTEXT_FILE__ placeholder support in process_template() - context_file field in init-options.json (init/switch/uninstall) - Per-integration tests: context file correctness, plan reference, init-options persistence (78 new context_file tests) - End-to-end CLI validation across all 28 integrations * fix: search for end marker after start marker in context section methods Address Copilot review: content.find(CONTEXT_MARKER_END) searched from the start of the file rather than after the located start marker. If the file contained a stray end marker before the start marker, the wrong slice could be replaced. Now both upsert_context_section() and remove_context_section() pass start_idx as the second argument to find() and validate end_idx > start_idx before performing the replacement. * fix: address Copilot review feedback on context section handling 1. Fix grammar in _build_context_section() directive text — add commas for a complete sentence. 2. Resolve __CONTEXT_FILE__ in resolve_skill_placeholders() — skills generated via extensions/presets for codex/kimi now replace the placeholder using the context_file value from init-options.json. 3. Handle Cursor .mdc frontmatter — when creating a new .mdc context file, prepend alwaysApply: true YAML frontmatter so Cursor auto-loads the rules. 4. Fix empty-file leading newline — when the context file exists but is empty, write the section directly instead of prepending a blank line. * fix: address second round of Copilot review feedback 1. Ensure .mdc frontmatter on existing files — upsert_context_section() now checks for missing YAML frontmatter on .mdc files during updates (not just creation), so pre-existing Cursor files get alwaysApply. 2. Guard against context_file=None — use 'or ""' instead of a default arg so explicit null values in init-options.json don't cause a TypeError in str.replace(). 3. Clean up .mdc files on removal — remove_context_section() treats files containing only the Speckit-generated frontmatter block as empty, deleting them rather than leaving orphaned frontmatter. * fix: address third round of Copilot review feedback 1. CRLF-safe .mdc frontmatter check — use lstrip().startswith('---') instead of startswith('---\n') so CRLF files don't get duplicate frontmatter. 2. CRLF-safe .mdc removal check — normalize line endings before comparing against the sentinel frontmatter string. 3. Call remove_context_section() during integration_uninstall() — the manifest-only uninstall was leaving the managed SPECKIT markers behind in the agent context file. 4. Fix stale docstring — remove 'agent_scripts' mention from test_lean_commands_have_no_scripts(). * fix: address fourth round of Copilot review feedback 1. Remove unused script_type parameter from _write_integration_json() and all 3 call sites — the parameter was no longer referenced after the update-context script removal. 2. Fix _build_context_section() docstring — correct example path from '.specify/plans/plan.md' to 'specs//plan.md'. 3. Improve .mdc frontmatter-only detection in remove_context_section() — use regex to match any YAML frontmatter block (not just the exact Speckit-generated one), so .mdc files with additional frontmatter keys are also cleaned up when no body content remains. * fix: handle corrupted markers and parse .mdc frontmatter robustly 1. Handle partial/corrupted markers in upsert_context_section() — if only the START marker exists (no END), replace from START through EOF. If only the END marker exists, replace from BOF through END. This keeps upsert idempotent even when a user accidentally deletes one marker. 2. Parse .mdc YAML frontmatter properly — new _ensure_mdc_frontmatter() helper parses existing frontmatter and ensures alwaysApply: true is set, rather than just checking for the --- delimiter. Handles missing frontmatter, existing frontmatter without alwaysApply, and already-correct frontmatter. * fix: preserve .mdc frontmatter, add tests, clean up on switch 1. Rewrite _ensure_mdc_frontmatter() with regex — preserves comments, formatting, and custom keys in existing frontmatter instead of destructively re-serializing via yaml.safe_dump(). Inserts or fixes alwaysApply: true in place. 2. Add 6 focused .mdc frontmatter tests to cursor-agent test file: new file creation, missing frontmatter, preserved custom keys, wrong alwaysApply value, idempotent upserts, removal cleanup. 3. Call remove_context_section() during integration switch Phase 1 — prevents stale SPECKIT markers from being left in the old integration's context file. Also clear context_file from init-options during the metadata reset. * fix: remove unused MDC_FRONTMATTER, preserve inline comments, normalize bare CR 1. Remove unused MDC_FRONTMATTER class variable — dead code after _ensure_mdc_frontmatter() was rewritten with regex. 2. Preserve inline comments when fixing alwaysApply — the regex substitution now captures trailing '# comment' text and keeps it. 3. Normalize bare CR in upsert_context_section() — match the behavior of remove_context_section() which already normalizes both CRLF and bare CR. 4. Clarify .mdc removal comment — 'treat frontmatter-only as empty' instead of misleading 'strip frontmatter'. * fix: handle corrupted markers in remove, CRLF-safe end-marker consumption 1. Handle corrupted markers in remove_context_section() — mirror upsert's behavior: start-only removes start→EOF, end-only removes BOF→end. Previously bailed out leaving partial markers behind. 2. CRLF-safe end-marker consumption — both upsert and remove now handle \r\n after the end marker, not just \n. Prevents extra blank lines at replacement boundaries in CRLF files. 3. Clarify path rule in plan template — distinguish filesystem operations (absolute paths) from documentation/agent context references (project-relative paths). * fix: only remove context section when both markers are well-ordered remove_context_section() previously treated mismatched markers as corruption and aggressively removed from BOF→end-marker or start-marker→EOF, which could delete user-authored content if only one marker remained. Now it only removes when both START and END markers exist and are properly ordered, returning False otherwise. --- pyproject.toml | 1 - scripts/bash/update-agent-context.sh | 857 ------------------ scripts/powershell/update-agent-context.ps1 | 515 ----------- src/specify_cli/__init__.py | 25 +- src/specify_cli/agents.py | 35 +- .../agy/scripts/update-context.ps1 | 17 - .../agy/scripts/update-context.sh | 24 - .../amp/scripts/update-context.ps1 | 23 - .../amp/scripts/update-context.sh | 28 - .../auggie/scripts/update-context.ps1 | 23 - .../auggie/scripts/update-context.sh | 28 - src/specify_cli/integrations/base.py | 294 +++++- .../bob/scripts/update-context.ps1 | 23 - .../bob/scripts/update-context.sh | 28 - .../claude/scripts/update-context.ps1 | 23 - .../claude/scripts/update-context.sh | 28 - .../codebuddy/scripts/update-context.ps1 | 23 - .../codebuddy/scripts/update-context.sh | 28 - .../codex/scripts/update-context.ps1 | 17 - .../codex/scripts/update-context.sh | 24 - .../integrations/copilot/__init__.py | 9 +- .../copilot/scripts/update-context.ps1 | 32 - .../copilot/scripts/update-context.sh | 37 - .../cursor_agent/scripts/update-context.ps1 | 23 - .../cursor_agent/scripts/update-context.sh | 28 - .../integrations/forge/__init__.py | 9 +- .../forge/scripts/update-context.ps1 | 33 - .../forge/scripts/update-context.sh | 38 - .../gemini/scripts/update-context.ps1 | 23 - .../gemini/scripts/update-context.sh | 28 - .../integrations/generic/__init__.py | 11 +- .../generic/scripts/update-context.ps1 | 17 - .../generic/scripts/update-context.sh | 24 - .../goose/scripts/update-context.ps1 | 33 - .../goose/scripts/update-context.sh | 38 - .../iflow/scripts/update-context.ps1 | 23 - .../iflow/scripts/update-context.sh | 28 - .../junie/scripts/update-context.ps1 | 23 - .../junie/scripts/update-context.sh | 28 - .../kilocode/scripts/update-context.ps1 | 23 - .../kilocode/scripts/update-context.sh | 28 - .../kimi/scripts/update-context.ps1 | 17 - .../kimi/scripts/update-context.sh | 24 - .../kiro_cli/scripts/update-context.ps1 | 23 - .../kiro_cli/scripts/update-context.sh | 28 - .../opencode/scripts/update-context.ps1 | 23 - .../opencode/scripts/update-context.sh | 28 - .../pi/scripts/update-context.ps1 | 23 - .../integrations/pi/scripts/update-context.sh | 28 - .../qodercli/scripts/update-context.ps1 | 23 - .../qodercli/scripts/update-context.sh | 28 - .../qwen/scripts/update-context.ps1 | 23 - .../qwen/scripts/update-context.sh | 28 - .../roo/scripts/update-context.ps1 | 23 - .../roo/scripts/update-context.sh | 28 - .../shai/scripts/update-context.ps1 | 23 - .../shai/scripts/update-context.sh | 28 - .../tabnine/scripts/update-context.ps1 | 23 - .../tabnine/scripts/update-context.sh | 28 - .../trae/scripts/update-context.ps1 | 23 - .../trae/scripts/update-context.sh | 28 - .../vibe/scripts/update-context.ps1 | 23 - .../vibe/scripts/update-context.sh | 28 - .../windsurf/scripts/update-context.ps1 | 23 - .../windsurf/scripts/update-context.sh | 28 - templates/agent-file-template.md | 28 - templates/commands/plan.md | 13 +- tests/integrations/test_cli.py | 11 +- .../test_integration_base_markdown.py | 96 +- .../test_integration_base_skills.py | 87 +- .../test_integration_base_toml.py | 98 +- .../test_integration_base_yaml.py | 98 +- .../integrations/test_integration_catalog.py | 4 +- tests/integrations/test_integration_claude.py | 20 +- .../integrations/test_integration_copilot.py | 25 +- .../test_integration_cursor_agent.py | 80 ++ tests/integrations/test_integration_forge.py | 32 +- .../integrations/test_integration_generic.py | 71 +- tests/test_agent_config_consistency.py | 119 --- tests/test_cursor_frontmatter.py | 266 ------ tests/test_extension_skills.py | 5 - tests/test_extensions.py | 18 - tests/test_presets.py | 4 +- 83 files changed, 756 insertions(+), 3521 deletions(-) delete mode 100644 scripts/bash/update-agent-context.sh delete mode 100644 scripts/powershell/update-agent-context.ps1 delete mode 100644 src/specify_cli/integrations/agy/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/agy/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/amp/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/amp/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/auggie/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/auggie/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/bob/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/bob/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/claude/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/claude/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/codebuddy/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/codex/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/codex/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/copilot/scripts/update-context.ps1 delete mode 100644 src/specify_cli/integrations/copilot/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/cursor_agent/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/forge/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/forge/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/gemini/scripts/update-context.ps1 delete mode 100644 src/specify_cli/integrations/gemini/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/generic/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/generic/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/goose/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/goose/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/iflow/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/iflow/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/junie/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/junie/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/kilocode/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/kilocode/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/kimi/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/kimi/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/kiro_cli/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/opencode/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/opencode/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/pi/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/pi/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/qodercli/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/qodercli/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/qwen/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/qwen/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/roo/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/roo/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/shai/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/shai/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/tabnine/scripts/update-context.ps1 delete mode 100644 src/specify_cli/integrations/tabnine/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/trae/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/trae/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/vibe/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/vibe/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/windsurf/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/windsurf/scripts/update-context.sh delete mode 100644 templates/agent-file-template.md delete mode 100644 tests/test_cursor_frontmatter.py diff --git a/pyproject.toml b/pyproject.toml index fc4b306351..dae79b0f6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ packages = ["src/specify_cli"] [tool.hatch.build.targets.wheel.force-include] # Bundle core assets so `specify init` works without network access (air-gapped / enterprise) # Page templates (exclude commands/ — bundled separately below to avoid duplication) -"templates/agent-file-template.md" = "specify_cli/core_pack/templates/agent-file-template.md" "templates/checklist-template.md" = "specify_cli/core_pack/templates/checklist-template.md" "templates/constitution-template.md" = "specify_cli/core_pack/templates/constitution-template.md" "templates/plan-template.md" = "specify_cli/core_pack/templates/plan-template.md" diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh deleted file mode 100644 index 2f71bb893c..0000000000 --- a/scripts/bash/update-agent-context.sh +++ /dev/null @@ -1,857 +0,0 @@ -#!/usr/bin/env bash - -# Update agent context files with information from plan.md -# -# This script maintains AI agent context files by parsing feature specifications -# and updating agent-specific configuration files with project information. -# -# MAIN FUNCTIONS: -# 1. Environment Validation -# - Verifies git repository structure and branch information -# - Checks for required plan.md files and templates -# - Validates file permissions and accessibility -# -# 2. Plan Data Extraction -# - Parses plan.md files to extract project metadata -# - Identifies language/version, frameworks, databases, and project types -# - Handles missing or incomplete specification data gracefully -# -# 3. Agent File Management -# - Creates new agent context files from templates when needed -# - Updates existing agent files with new project information -# - Preserves manual additions and custom configurations -# - Supports multiple AI agent formats and directory structures -# -# 4. Content Generation -# - Generates language-specific build/test commands -# - Creates appropriate project directory structures -# - Updates technology stacks and recent changes sections -# - Maintains consistent formatting and timestamps -# -# 5. Multi-Agent Support -# - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Goose, Antigravity or Generic -# - Can update single agents or all existing agent files -# - Creates default Claude file if no agent files exist -# -# Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic -# Leave empty to update all existing agent files - -set -e - -# Enable strict error handling -set -u -set -o pipefail - -#============================================================================== -# Configuration and Global Variables -#============================================================================== - -# Get script directory and load common functions -SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -# Get all paths and variables from common functions -_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } -eval "$_paths_output" -unset _paths_output - -NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code -AGENT_TYPE="${1:-}" - -# Agent-specific file paths -CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" -GEMINI_FILE="$REPO_ROOT/GEMINI.md" -COPILOT_FILE="$REPO_ROOT/.github/copilot-instructions.md" -CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc" -QWEN_FILE="$REPO_ROOT/QWEN.md" -AGENTS_FILE="$REPO_ROOT/AGENTS.md" -WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" -JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.md" -KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md" -AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" -ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" -CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" -QODER_FILE="$REPO_ROOT/QODER.md" -# Amp, Kiro CLI, IBM Bob, Pi, Forge, and Goose all share AGENTS.md — use AGENTS_FILE to avoid -# updating the same file multiple times. -AMP_FILE="$AGENTS_FILE" -SHAI_FILE="$REPO_ROOT/SHAI.md" -TABNINE_FILE="$REPO_ROOT/TABNINE.md" -KIRO_FILE="$AGENTS_FILE" -AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" -BOB_FILE="$AGENTS_FILE" -VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" -KIMI_FILE="$REPO_ROOT/KIMI.md" -TRAE_FILE="$REPO_ROOT/.trae/rules/project_rules.md" -IFLOW_FILE="$REPO_ROOT/IFLOW.md" -FORGE_FILE="$AGENTS_FILE" - -# Template file -TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" - -# Global variables for parsed plan data -NEW_LANG="" -NEW_FRAMEWORK="" -NEW_DB="" -NEW_PROJECT_TYPE="" - -#============================================================================== -# Utility Functions -#============================================================================== - -log_info() { - echo "INFO: $1" -} - -log_success() { - echo "✓ $1" -} - -log_error() { - echo "ERROR: $1" >&2 -} - -log_warning() { - echo "WARNING: $1" >&2 -} - -# Track temporary files for cleanup on interrupt -_CLEANUP_FILES=() - -# Cleanup function for temporary files -cleanup() { - local exit_code=$? - # Disarm traps to prevent re-entrant loop - trap - EXIT INT TERM - if [ ${#_CLEANUP_FILES[@]} -gt 0 ]; then - for f in "${_CLEANUP_FILES[@]}"; do - rm -f "$f" "$f.bak" "$f.tmp" - done - fi - exit $exit_code -} - -# Set up cleanup trap -trap cleanup EXIT INT TERM - -#============================================================================== -# Validation Functions -#============================================================================== - -validate_environment() { - # Check if we have a current branch/feature (git or non-git) - if [[ -z "$CURRENT_BRANCH" ]]; then - log_error "Unable to determine current feature" - if [[ "$HAS_GIT" == "true" ]]; then - log_info "Make sure you're on a feature branch" - else - log_info "Set SPECIFY_FEATURE environment variable or create a feature first" - fi - exit 1 - fi - - # Check if plan.md exists - if [[ ! -f "$NEW_PLAN" ]]; then - log_error "No plan.md found at $NEW_PLAN" - log_info "Make sure you're working on a feature with a corresponding spec directory" - if [[ "$HAS_GIT" != "true" ]]; then - log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first" - fi - exit 1 - fi - - # Check if template exists (needed for new files) - if [[ ! -f "$TEMPLATE_FILE" ]]; then - log_warning "Template file not found at $TEMPLATE_FILE" - log_warning "Creating new agent files will fail" - fi -} - -#============================================================================== -# Plan Parsing Functions -#============================================================================== - -extract_plan_field() { - local field_pattern="$1" - local plan_file="$2" - - grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null | \ - head -1 | \ - sed "s|^\*\*${field_pattern}\*\*: ||" | \ - sed 's/^[ \t]*//;s/[ \t]*$//' | \ - grep -v "NEEDS CLARIFICATION" | \ - grep -v "^N/A$" || echo "" -} - -parse_plan_data() { - local plan_file="$1" - - if [[ ! -f "$plan_file" ]]; then - log_error "Plan file not found: $plan_file" - return 1 - fi - - if [[ ! -r "$plan_file" ]]; then - log_error "Plan file is not readable: $plan_file" - return 1 - fi - - log_info "Parsing plan data from $plan_file" - - NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file") - NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file") - NEW_DB=$(extract_plan_field "Storage" "$plan_file") - NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file") - - # Log what we found - if [[ -n "$NEW_LANG" ]]; then - log_info "Found language: $NEW_LANG" - else - log_warning "No language information found in plan" - fi - - if [[ -n "$NEW_FRAMEWORK" ]]; then - log_info "Found framework: $NEW_FRAMEWORK" - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then - log_info "Found database: $NEW_DB" - fi - - if [[ -n "$NEW_PROJECT_TYPE" ]]; then - log_info "Found project type: $NEW_PROJECT_TYPE" - fi -} - -format_technology_stack() { - local lang="$1" - local framework="$2" - local parts=() - - # Add non-empty parts - [[ -n "$lang" && "$lang" != "NEEDS CLARIFICATION" ]] && parts+=("$lang") - [[ -n "$framework" && "$framework" != "NEEDS CLARIFICATION" && "$framework" != "N/A" ]] && parts+=("$framework") - - # Join with proper formatting - if [[ ${#parts[@]} -eq 0 ]]; then - echo "" - elif [[ ${#parts[@]} -eq 1 ]]; then - echo "${parts[0]}" - else - # Join multiple parts with " + " - local result="${parts[0]}" - for ((i=1; i<${#parts[@]}; i++)); do - result="$result + ${parts[i]}" - done - echo "$result" - fi -} - -#============================================================================== -# Template and Content Generation Functions -#============================================================================== - -get_project_structure() { - local project_type="$1" - - if [[ "$project_type" == *"web"* ]]; then - echo "backend/\\nfrontend/\\ntests/" - else - echo "src/\\ntests/" - fi -} - -get_commands_for_language() { - local lang="$1" - - case "$lang" in - *"Python"*) - echo "cd src && pytest && ruff check ." - ;; - *"Rust"*) - echo "cargo test && cargo clippy" - ;; - *"JavaScript"*|*"TypeScript"*) - echo "npm test && npm run lint" - ;; - *) - echo "# Add commands for $lang" - ;; - esac -} - -get_language_conventions() { - local lang="$1" - echo "$lang: Follow standard conventions" -} - -# Escape sed replacement-side specials for | delimiter. -# & and \ are replacement-side specials; | is our sed delimiter. -_esc_sed() { printf '%s\n' "$1" | sed 's/[\\&|]/\\&/g'; } - -create_new_agent_file() { - local target_file="$1" - local temp_file="$2" - local project_name - project_name=$(_esc_sed "$3") - local current_date="$4" - - if [[ ! -f "$TEMPLATE_FILE" ]]; then - log_error "Template not found at $TEMPLATE_FILE" - return 1 - fi - - if [[ ! -r "$TEMPLATE_FILE" ]]; then - log_error "Template file is not readable: $TEMPLATE_FILE" - return 1 - fi - - log_info "Creating new agent context file from template..." - - if ! cp "$TEMPLATE_FILE" "$temp_file"; then - log_error "Failed to copy template file" - return 1 - fi - - # Replace template placeholders - local project_structure - project_structure=$(get_project_structure "$NEW_PROJECT_TYPE") - project_structure=$(_esc_sed "$project_structure") - - local commands - commands=$(get_commands_for_language "$NEW_LANG") - - local language_conventions - language_conventions=$(get_language_conventions "$NEW_LANG") - - local escaped_lang=$(_esc_sed "$NEW_LANG") - local escaped_framework=$(_esc_sed "$NEW_FRAMEWORK") - commands=$(_esc_sed "$commands") - language_conventions=$(_esc_sed "$language_conventions") - local escaped_branch=$(_esc_sed "$CURRENT_BRANCH") - - # Build technology stack and recent change strings conditionally - local tech_stack - if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then - tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)" - elif [[ -n "$escaped_lang" ]]; then - tech_stack="- $escaped_lang ($escaped_branch)" - elif [[ -n "$escaped_framework" ]]; then - tech_stack="- $escaped_framework ($escaped_branch)" - else - tech_stack="- ($escaped_branch)" - fi - - local recent_change - if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then - recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework" - elif [[ -n "$escaped_lang" ]]; then - recent_change="- $escaped_branch: Added $escaped_lang" - elif [[ -n "$escaped_framework" ]]; then - recent_change="- $escaped_branch: Added $escaped_framework" - else - recent_change="- $escaped_branch: Added" - fi - - local substitutions=( - "s|\[PROJECT NAME\]|$project_name|" - "s|\[DATE\]|$current_date|" - "s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|" - "s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g" - "s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|" - "s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|" - "s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|" - ) - - for substitution in "${substitutions[@]}"; do - if ! sed -i.bak -e "$substitution" "$temp_file"; then - log_error "Failed to perform substitution: $substitution" - rm -f "$temp_file" "$temp_file.bak" - return 1 - fi - done - - # Convert literal \n sequences to actual newlines (portable — works on BSD + GNU) - awk '{gsub(/\\n/,"\n")}1' "$temp_file" > "$temp_file.tmp" - mv "$temp_file.tmp" "$temp_file" - - # Clean up backup files from sed -i.bak - rm -f "$temp_file.bak" - - # Prepend Cursor frontmatter for .mdc files so rules are auto-included - if [[ "$target_file" == *.mdc ]]; then - local frontmatter_file - frontmatter_file=$(mktemp) || return 1 - _CLEANUP_FILES+=("$frontmatter_file") - printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" - cat "$temp_file" >> "$frontmatter_file" - mv "$frontmatter_file" "$temp_file" - fi - - return 0 -} - - - - -update_existing_agent_file() { - local target_file="$1" - local current_date="$2" - - log_info "Updating existing agent context file..." - - # Use a single temporary file for atomic update - local temp_file - temp_file=$(mktemp) || { - log_error "Failed to create temporary file" - return 1 - } - _CLEANUP_FILES+=("$temp_file") - - # Process the file in one pass - local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK") - local new_tech_entries=() - local new_change_entry="" - - # Prepare new technology entries - if [[ -n "$tech_stack" ]] && ! grep -q "$tech_stack" "$target_file"; then - new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)") - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then - new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)") - fi - - # Prepare new change entry - if [[ -n "$tech_stack" ]]; then - new_change_entry="- $CURRENT_BRANCH: Added $tech_stack" - elif [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]]; then - new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB" - fi - - # Check if sections exist in the file - local has_active_technologies=0 - local has_recent_changes=0 - - if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then - has_active_technologies=1 - fi - - if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then - has_recent_changes=1 - fi - - # Process file line by line - local in_tech_section=false - local in_changes_section=false - local tech_entries_added=false - local changes_entries_added=false - local existing_changes_count=0 - local file_ended=false - - while IFS= read -r line || [[ -n "$line" ]]; do - # Handle Active Technologies section - if [[ "$line" == "## Active Technologies" ]]; then - echo "$line" >> "$temp_file" - in_tech_section=true - continue - elif [[ $in_tech_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then - # Add new tech entries before closing the section - if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - echo "$line" >> "$temp_file" - in_tech_section=false - continue - elif [[ $in_tech_section == true ]] && [[ -z "$line" ]]; then - # Add new tech entries before empty line in tech section - if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - echo "$line" >> "$temp_file" - continue - fi - - # Handle Recent Changes section - if [[ "$line" == "## Recent Changes" ]]; then - echo "$line" >> "$temp_file" - # Add new change entry right after the heading - if [[ -n "$new_change_entry" ]]; then - echo "$new_change_entry" >> "$temp_file" - fi - in_changes_section=true - changes_entries_added=true - continue - elif [[ $in_changes_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then - echo "$line" >> "$temp_file" - in_changes_section=false - continue - elif [[ $in_changes_section == true ]] && [[ "$line" == "- "* ]]; then - # Keep only first 2 existing changes - if [[ $existing_changes_count -lt 2 ]]; then - echo "$line" >> "$temp_file" - ((existing_changes_count++)) - fi - continue - fi - - # Update timestamp - if [[ "$line" =~ (\*\*)?Last\ updated(\*\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then - echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file" - else - echo "$line" >> "$temp_file" - fi - done < "$target_file" - - # Post-loop check: if we're still in the Active Technologies section and haven't added new entries - if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - - # If sections don't exist, add them at the end of the file - if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - echo "" >> "$temp_file" - echo "## Active Technologies" >> "$temp_file" - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - - if [[ $has_recent_changes -eq 0 ]] && [[ -n "$new_change_entry" ]]; then - echo "" >> "$temp_file" - echo "## Recent Changes" >> "$temp_file" - echo "$new_change_entry" >> "$temp_file" - changes_entries_added=true - fi - - # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion - if [[ "$target_file" == *.mdc ]]; then - if ! head -1 "$temp_file" | grep -q '^---'; then - local frontmatter_file - frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; } - _CLEANUP_FILES+=("$frontmatter_file") - printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" - cat "$temp_file" >> "$frontmatter_file" - mv "$frontmatter_file" "$temp_file" - fi - fi - - # Move temp file to target atomically - if ! mv "$temp_file" "$target_file"; then - log_error "Failed to update target file" - rm -f "$temp_file" - return 1 - fi - - return 0 -} -#============================================================================== -# Main Agent File Update Function -#============================================================================== - -update_agent_file() { - local target_file="$1" - local agent_name="$2" - - if [[ -z "$target_file" ]] || [[ -z "$agent_name" ]]; then - log_error "update_agent_file requires target_file and agent_name parameters" - return 1 - fi - - log_info "Updating $agent_name context file: $target_file" - - local project_name - project_name=$(basename "$REPO_ROOT") - local current_date - current_date=$(date +%Y-%m-%d) - - # Create directory if it doesn't exist - local target_dir - target_dir=$(dirname "$target_file") - if [[ ! -d "$target_dir" ]]; then - if ! mkdir -p "$target_dir"; then - log_error "Failed to create directory: $target_dir" - return 1 - fi - fi - - if [[ ! -f "$target_file" ]]; then - # Create new file from template - local temp_file - temp_file=$(mktemp) || { - log_error "Failed to create temporary file" - return 1 - } - _CLEANUP_FILES+=("$temp_file") - - if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then - if mv "$temp_file" "$target_file"; then - log_success "Created new $agent_name context file" - else - log_error "Failed to move temporary file to $target_file" - rm -f "$temp_file" - return 1 - fi - else - log_error "Failed to create new agent file" - rm -f "$temp_file" - return 1 - fi - else - # Update existing file - if [[ ! -r "$target_file" ]]; then - log_error "Cannot read existing file: $target_file" - return 1 - fi - - if [[ ! -w "$target_file" ]]; then - log_error "Cannot write to existing file: $target_file" - return 1 - fi - - if update_existing_agent_file "$target_file" "$current_date"; then - log_success "Updated existing $agent_name context file" - else - log_error "Failed to update existing agent file" - return 1 - fi - fi - - return 0 -} - -#============================================================================== -# Agent Selection and Processing -#============================================================================== - -update_specific_agent() { - local agent_type="$1" - - case "$agent_type" in - claude) - update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 - ;; - gemini) - update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1 - ;; - copilot) - update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1 - ;; - cursor-agent) - update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1 - ;; - qwen) - update_agent_file "$QWEN_FILE" "Qwen Code" || return 1 - ;; - opencode) - update_agent_file "$AGENTS_FILE" "opencode" || return 1 - ;; - codex) - update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1 - ;; - windsurf) - update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1 - ;; - junie) - update_agent_file "$JUNIE_FILE" "Junie" || return 1 - ;; - kilocode) - update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1 - ;; - auggie) - update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1 - ;; - roo) - update_agent_file "$ROO_FILE" "Roo Code" || return 1 - ;; - codebuddy) - update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1 - ;; - qodercli) - update_agent_file "$QODER_FILE" "Qoder CLI" || return 1 - ;; - amp) - update_agent_file "$AMP_FILE" "Amp" || return 1 - ;; - shai) - update_agent_file "$SHAI_FILE" "SHAI" || return 1 - ;; - tabnine) - update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1 - ;; - kiro-cli) - update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1 - ;; - agy) - update_agent_file "$AGY_FILE" "Antigravity" || return 1 - ;; - bob) - update_agent_file "$BOB_FILE" "IBM Bob" || return 1 - ;; - vibe) - update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1 - ;; - kimi) - update_agent_file "$KIMI_FILE" "Kimi Code" || return 1 - ;; - trae) - update_agent_file "$TRAE_FILE" "Trae" || return 1 - ;; - pi) - update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1 - ;; - iflow) - update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1 - ;; - forge) - update_agent_file "$AGENTS_FILE" "Forge" || return 1 - ;; - goose) - update_agent_file "$AGENTS_FILE" "Goose" || return 1 - ;; - generic) - log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." - ;; - *) - log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic" - exit 1 - ;; - esac -} - -# Helper: skip non-existent files and files already updated (dedup by -# realpath so that variables pointing to the same file — e.g. AMP_FILE, -# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). -# Uses a linear array instead of associative array for bash 3.2 compatibility. -# Note: defined at top level because bash 3.2 does not support true -# nested/local functions. _updated_paths, _found_agent, and _all_ok are -# initialised exclusively inside update_all_existing_agents so that -# sourcing this script has no side effects on the caller's environment. - -_update_if_new() { - local file="$1" name="$2" - [[ -f "$file" ]] || return 0 - local real_path - real_path=$(realpath "$file" 2>/dev/null || echo "$file") - local p - if [[ ${#_updated_paths[@]} -gt 0 ]]; then - for p in "${_updated_paths[@]}"; do - [[ "$p" == "$real_path" ]] && return 0 - done - fi - # Record the file as seen before attempting the update so that: - # (a) aliases pointing to the same path are not retried on failure - # (b) _found_agent reflects file existence, not update success - _updated_paths+=("$real_path") - _found_agent=true - update_agent_file "$file" "$name" -} - -update_all_existing_agents() { - _found_agent=false - _updated_paths=() - local _all_ok=true - - _update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false - _update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false - _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false - _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false - _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false - _update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose" || _all_ok=false - _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false - _update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false - _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false - _update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false - _update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false - _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false - _update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false - _update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false - _update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false - _update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false - _update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false - _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false - _update_if_new "$TRAE_FILE" "Trae" || _all_ok=false - _update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false - - # If no agent files exist, create a default Claude file - if [[ "$_found_agent" == false ]]; then - log_info "No existing agent files found, creating default Claude file..." - update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 - fi - - [[ "$_all_ok" == true ]] -} -print_summary() { - echo - log_info "Summary of changes:" - - if [[ -n "$NEW_LANG" ]]; then - echo " - Added language: $NEW_LANG" - fi - - if [[ -n "$NEW_FRAMEWORK" ]]; then - echo " - Added framework: $NEW_FRAMEWORK" - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then - echo " - Added database: $NEW_DB" - fi - - echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]" -} - -#============================================================================== -# Main Execution -#============================================================================== - -main() { - # Validate environment before proceeding - validate_environment - - log_info "=== Updating agent context files for feature $CURRENT_BRANCH ===" - - # Parse the plan file to extract project information - if ! parse_plan_data "$NEW_PLAN"; then - log_error "Failed to parse plan data" - exit 1 - fi - - # Process based on agent type argument - local success=true - - if [[ -z "$AGENT_TYPE" ]]; then - # No specific agent provided - update all existing agent files - log_info "No agent specified, updating all existing agent files..." - if ! update_all_existing_agents; then - success=false - fi - else - # Specific agent provided - update only that agent - log_info "Updating specific agent: $AGENT_TYPE" - if ! update_specific_agent "$AGENT_TYPE"; then - success=false - fi - fi - - # Print summary - print_summary - - if [[ "$success" == true ]]; then - log_success "Agent context update completed successfully" - exit 0 - else - log_error "Agent context update completed with errors" - exit 1 - fi -} - -# Execute main function if script is run directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 deleted file mode 100644 index 3ee45d383c..0000000000 --- a/scripts/powershell/update-agent-context.ps1 +++ /dev/null @@ -1,515 +0,0 @@ -#!/usr/bin/env pwsh -<#! -.SYNOPSIS -Update agent context files with information from plan.md (PowerShell version) - -.DESCRIPTION -Mirrors the behavior of scripts/bash/update-agent-context.sh: - 1. Environment Validation - 2. Plan Data Extraction - 3. Agent File Management (create from template or update existing) - 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, goose, generic) - -.PARAMETER AgentType -Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). - -.EXAMPLE -./update-agent-context.ps1 -AgentType claude - -.EXAMPLE -./update-agent-context.ps1 # Updates all existing agent files - -.NOTES -Relies on common helper functions in common.ps1 -#> -param( - [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','goose','generic')] - [string]$AgentType -) - -$ErrorActionPreference = 'Stop' - -# Import common helpers -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -. (Join-Path $ScriptDir 'common.ps1') - -# Acquire environment paths -$envData = Get-FeaturePathsEnv -$REPO_ROOT = $envData.REPO_ROOT -$CURRENT_BRANCH = $envData.CURRENT_BRANCH -$HAS_GIT = $envData.HAS_GIT -$IMPL_PLAN = $envData.IMPL_PLAN -$NEW_PLAN = $IMPL_PLAN - -# Agent file paths -$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md' -$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md' -$COPILOT_FILE = Join-Path $REPO_ROOT '.github/copilot-instructions.md' -$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc' -$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md' -$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md' -$JUNIE_FILE = Join-Path $REPO_ROOT '.junie/AGENTS.md' -$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md' -$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md' -$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md' -$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md' -$QODER_FILE = Join-Path $REPO_ROOT 'QODER.md' -$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' -$TABNINE_FILE = Join-Path $REPO_ROOT 'TABNINE.md' -$KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' -$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' -$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' -$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.md' -$IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md' -$FORGE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$GOOSE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' - -$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' - -# Parsed plan data placeholders -$script:NEW_LANG = '' -$script:NEW_FRAMEWORK = '' -$script:NEW_DB = '' -$script:NEW_PROJECT_TYPE = '' - -function Write-Info { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "INFO: $Message" -} - -function Write-Success { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "$([char]0x2713) $Message" -} - -function Write-WarningMsg { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Warning $Message -} - -function Write-Err { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "ERROR: $Message" -ForegroundColor Red -} - -function Validate-Environment { - if (-not $CURRENT_BRANCH) { - Write-Err 'Unable to determine current feature' - if ($HAS_GIT) { Write-Info "Make sure you're on a feature branch" } else { Write-Info 'Set SPECIFY_FEATURE environment variable or create a feature first' } - exit 1 - } - if (-not (Test-Path $NEW_PLAN)) { - Write-Err "No plan.md found at $NEW_PLAN" - Write-Info 'Ensure you are working on a feature with a corresponding spec directory' - if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' } - exit 1 - } - if (-not (Test-Path $TEMPLATE_FILE)) { - Write-Err "Template file not found at $TEMPLATE_FILE" - Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.' - exit 1 - } -} - -function Extract-PlanField { - param( - [Parameter(Mandatory=$true)] - [string]$FieldPattern, - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { return '' } - # Lines like **Language/Version**: Python 3.12 - $regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$" - Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object { - if ($_ -match $regex) { - $val = $Matches[1].Trim() - if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val } - } - } | Select-Object -First 1 -} - -function Parse-PlanData { - param( - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { Write-Err "Plan file not found: $PlanFile"; return $false } - Write-Info "Parsing plan data from $PlanFile" - $script:NEW_LANG = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile - $script:NEW_FRAMEWORK = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile - $script:NEW_DB = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile - $script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile - - if ($NEW_LANG) { Write-Info "Found language: $NEW_LANG" } else { Write-WarningMsg 'No language information found in plan' } - if ($NEW_FRAMEWORK) { Write-Info "Found framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info "Found database: $NEW_DB" } - if ($NEW_PROJECT_TYPE) { Write-Info "Found project type: $NEW_PROJECT_TYPE" } - return $true -} - -function Format-TechnologyStack { - param( - [Parameter(Mandatory=$false)] - [string]$Lang, - [Parameter(Mandatory=$false)] - [string]$Framework - ) - $parts = @() - if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang } - if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework } - if (-not $parts) { return '' } - return ($parts -join ' + ') -} - -function Get-ProjectStructure { - param( - [Parameter(Mandatory=$false)] - [string]$ProjectType - ) - if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" } -} - -function Get-CommandsForLanguage { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - switch -Regex ($Lang) { - 'Python' { return "cd src; pytest; ruff check ." } - 'Rust' { return "cargo test; cargo clippy" } - 'JavaScript|TypeScript' { return "npm test; npm run lint" } - default { return "# Add commands for $Lang" } - } -} - -function Get-LanguageConventions { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' } -} - -function New-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$ProjectName, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err "Template not found at $TEMPLATE_FILE"; return $false } - $temp = New-TemporaryFile - Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force - - $projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE - $commands = Get-CommandsForLanguage -Lang $NEW_LANG - $languageConventions = Get-LanguageConventions -Lang $NEW_LANG - - $escaped_lang = $NEW_LANG - $escaped_framework = $NEW_FRAMEWORK - $escaped_branch = $CURRENT_BRANCH - - $content = Get-Content -LiteralPath $temp -Raw -Encoding utf8 - $content = $content -replace '\[PROJECT NAME\]',$ProjectName - $content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd') - - # Build the technology stack string safely - $techStackForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $techStackForTemplate = "- $escaped_lang + $escaped_framework ($escaped_branch)" - } elseif ($escaped_lang) { - $techStackForTemplate = "- $escaped_lang ($escaped_branch)" - } elseif ($escaped_framework) { - $techStackForTemplate = "- $escaped_framework ($escaped_branch)" - } - - $content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate - # For project structure we manually embed (keep newlines) - $escapedStructure = [Regex]::Escape($projectStructure) - $content = $content -replace '\[ACTUAL STRUCTURE FROM PLANS\]',$escapedStructure - # Replace escaped newlines placeholder after all replacements - $content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands - $content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions - - # Build the recent changes string safely - $recentChangesForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}" - } elseif ($escaped_lang) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang}" - } elseif ($escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}" - } - - $content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate - # Convert literal \n sequences introduced by Escape to real newlines - $content = $content -replace '\\n',[Environment]::NewLine - - # Prepend Cursor frontmatter for .mdc files so rules are auto-included - if ($TargetFile -match '\.mdc$') { - $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') -join [Environment]::NewLine - $content = $frontmatter + $content - } - - $parent = Split-Path -Parent $TargetFile - if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null } - Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8 - Remove-Item $temp -Force - return $true -} - -function Update-ExistingAgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) } - - $techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK - $newTechEntries = @() - if ($techStack) { - $escapedTechStack = [Regex]::Escape($techStack) - if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) { - $newTechEntries += "- $techStack ($CURRENT_BRANCH)" - } - } - if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { - $escapedDB = [Regex]::Escape($NEW_DB) - if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) { - $newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)" - } - } - $newChangeEntry = '' - if ($techStack) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${techStack}" } - elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${NEW_DB}" } - - $lines = Get-Content -LiteralPath $TargetFile -Encoding utf8 - $output = New-Object System.Collections.Generic.List[string] - $inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0 - - for ($i=0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -eq '## Active Technologies') { - $output.Add($line) - $inTech = $true - continue - } - if ($inTech -and $line -match '^##\s') { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); $inTech = $false; continue - } - if ($inTech -and [string]::IsNullOrWhiteSpace($line)) { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); continue - } - if ($line -eq '## Recent Changes') { - $output.Add($line) - if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true } - $inChanges = $true - continue - } - if ($inChanges -and $line -match '^##\s') { $output.Add($line); $inChanges = $false; continue } - if ($inChanges -and $line -match '^- ') { - if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ } - continue - } - if ($line -match '(\*\*)?Last updated(\*\*)?: .*\d{4}-\d{2}-\d{2}') { - $output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd'))) - continue - } - $output.Add($line) - } - - # Post-loop check: if we're still in the Active Technologies section and haven't added new entries - if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) { - $newTechEntries | ForEach-Object { $output.Add($_) } - } - - # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion - if ($TargetFile -match '\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') { - $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') - $output.InsertRange(0, $frontmatter) - } - - Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8 - return $true -} - -function Update-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$AgentName - ) - if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false } - Write-Info "Updating $AgentName context file: $TargetFile" - $projectName = Split-Path $REPO_ROOT -Leaf - $date = Get-Date - - $dir = Split-Path -Parent $TargetFile - if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } - - if (-not (Test-Path $TargetFile)) { - if (New-AgentFile -TargetFile $TargetFile -ProjectName $projectName -Date $date) { Write-Success "Created new $AgentName context file" } else { Write-Err 'Failed to create new agent file'; return $false } - } else { - try { - if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success "Updated existing $AgentName context file" } else { Write-Err 'Failed to update agent file'; return $false } - } catch { - Write-Err "Cannot access or update existing file: $TargetFile. $_" - return $false - } - } - return $true -} - -function Update-SpecificAgent { - param( - [Parameter(Mandatory=$true)] - [string]$Type - ) - switch ($Type) { - 'claude' { Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code' } - 'gemini' { Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI' } - 'copilot' { Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot' } - 'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE' } - 'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' } - 'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' } - 'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' } - 'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' } - 'junie' { Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie' } - 'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' } - 'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } - 'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } - 'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' } - 'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } - 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } - 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } - 'tabnine' { Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI' } - 'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' } - 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } - 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } - 'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } - 'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' } - 'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' } - 'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' } - 'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' } - 'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' } - 'goose' { Update-AgentFile -TargetFile $GOOSE_FILE -AgentName 'Goose' } - 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic'; return $false } - } -} - -function Update-AllExistingAgents { - $found = $false - $ok = $true - $updatedPaths = @() - - # Helper function to update only if file exists and hasn't been updated yet - function Update-IfNew { - param( - [Parameter(Mandatory=$true)] - [string]$FilePath, - [Parameter(Mandatory=$true)] - [string]$AgentName - ) - - if (-not (Test-Path $FilePath)) { return $true } - - # Get the real path to detect duplicates (e.g., AMP_FILE, KIRO_FILE, BOB_FILE all point to AGENTS.md) - $realPath = (Get-Item -LiteralPath $FilePath).FullName - - # Check if we've already updated this file - if ($updatedPaths -contains $realPath) { - return $true - } - - # Record the file as seen before attempting the update - # Use parent scope (1) to modify Update-AllExistingAgents' local variables - Set-Variable -Name updatedPaths -Value ($updatedPaths + $realPath) -Scope 1 - Set-Variable -Name found -Value $true -Scope 1 - - # Perform the update - return (Update-AgentFile -TargetFile $FilePath -AgentName $AgentName) - } - - if (-not (Update-IfNew -FilePath $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false } - if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false } - if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose')) { $ok = $false } - if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false } - if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false } - if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $ROO_FILE -AgentName 'Roo Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $SHAI_FILE -AgentName 'SHAI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AGY_FILE -AgentName 'Antigravity')) { $ok = $false } - if (-not (Update-IfNew -FilePath $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false } - if (-not (Update-IfNew -FilePath $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $TRAE_FILE -AgentName 'Trae')) { $ok = $false } - if (-not (Update-IfNew -FilePath $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false } - - if (-not $found) { - Write-Info 'No existing agent files found, creating default Claude file...' - if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } - } - return $ok -} - -function Print-Summary { - Write-Host '' - Write-Info 'Summary of changes:' - if ($NEW_LANG) { Write-Host " - Added language: $NEW_LANG" } - if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } - Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]' -} - -function Main { - Validate-Environment - Write-Info "=== Updating agent context files for feature $CURRENT_BRANCH ===" - if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 } - $success = $true - if ($AgentType) { - Write-Info "Updating specific agent: $AgentType" - if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false } - } - else { - Write-Info 'No agent specified, updating all existing agent files...' - if (-not (Update-AllExistingAgents)) { $success = $false } - } - Print-Summary - if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 } -} - -Main diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0608e7a8ac..8c6fd02b9f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1261,15 +1261,11 @@ def init( manifest.save() # Write .specify/integration.json - script_ext = "sh" if selected_script == "sh" else "ps1" integration_json = project_path / ".specify" / "integration.json" integration_json.parent.mkdir(parents=True, exist_ok=True) integration_json.write_text(json.dumps({ "integration": resolved_integration.key, "version": get_speckit_version(), - "scripts": { - "update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}", - }, }, indent=2) + "\n", encoding="utf-8") tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) @@ -1373,6 +1369,7 @@ def init( "ai": selected_ai, "integration": resolved_integration.key, "branch_numbering": branch_numbering or "sequential", + "context_file": resolved_integration.context_file, "here": here, "preset": preset, "script": selected_script, @@ -1737,18 +1734,13 @@ def _read_integration_json(project_root: Path) -> dict[str, Any]: def _write_integration_json( project_root: Path, integration_key: str, - script_type: str, ) -> None: """Write ``.specify/integration.json`` for *integration_key*.""" - script_ext = "sh" if script_type == "sh" else "ps1" dest = project_root / INTEGRATION_JSON dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(json.dumps({ "integration": integration_key, "version": get_speckit_version(), - "scripts": { - "update-context": f".specify/integrations/{integration_key}/scripts/update-context.{script_ext}", - }, }, indent=2) + "\n", encoding="utf-8") @@ -1936,7 +1928,7 @@ def integration_install( raw_options=integration_options, ) manifest.save() - _write_integration_json(project_root, integration.key, selected_script) + _write_integration_json(project_root, integration.key) _update_init_options_for_integration(project_root, integration, script_type=selected_script) except Exception as e: @@ -2013,6 +2005,7 @@ def _update_init_options_for_integration( opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key + opts["context_file"] = integration.context_file if script_type: opts["script"] = script_type if isinstance(integration, SkillsIntegration): @@ -2064,6 +2057,7 @@ def integration_uninstall( opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) + opts.pop("context_file", None) save_init_options(project_root, opts) raise typer.Exit(0) @@ -2082,6 +2076,10 @@ def integration_uninstall( removed, skipped = manifest.uninstall(project_root, force=force) + # Remove managed context section from the agent context file + if integration: + integration.remove_context_section(project_root) + _remove_integration_json(project_root) # Update init-options.json to clear the integration @@ -2090,6 +2088,7 @@ def integration_uninstall( opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) + opts.pop("context_file", None) save_init_options(project_root, opts) name = (integration.config or {}).get("name", key) if integration else key @@ -2156,6 +2155,7 @@ def integration_switch( ) raise typer.Exit(1) removed, skipped = old_manifest.uninstall(project_root, force=force) + current_integration.remove_context_section(project_root) if removed: console.print(f" Removed {len(removed)} file(s)") if skipped: @@ -2186,6 +2186,7 @@ def integration_switch( opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) + opts.pop("context_file", None) save_init_options(project_root, opts) # Ensure shared infrastructure is present (safe to run unconditionally; @@ -2212,7 +2213,7 @@ def integration_switch( raw_options=integration_options, ) manifest.save() - _write_integration_json(project_root, target_integration.key, selected_script) + _write_integration_json(project_root, target_integration.key) _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) except Exception as e: @@ -2320,7 +2321,7 @@ def integration_upgrade( raw_options=integration_options, ) new_manifest.save() - _write_integration_json(project_root, key, selected_script) + _write_integration_json(project_root, key) _update_init_options_for_integration(project_root, integration, script_type=selected_script) except Exception as exc: # Don't teardown — setup overwrites in-place, so teardown would diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 32fc6cdbf0..1a0e5a8317 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -110,9 +110,9 @@ def _adjust_script_paths(self, frontmatter: dict) -> dict: """Normalize script paths in frontmatter to generated project locations. Rewrites known repo-relative and top-level script paths under the - `scripts` and `agent_scripts` keys (for example `../../scripts/`, - `../../templates/`, `../../memory/`, `scripts/`, `templates/`, and - `memory/`) to the `.specify/...` paths used in generated projects. + ``scripts`` key (for example ``../../scripts/``, + ``../../templates/``, ``../../memory/``, ``scripts/``, ``templates/``, and + ``memory/``) to the ``.specify/...`` paths used in generated projects. Args: frontmatter: Frontmatter dictionary @@ -122,11 +122,8 @@ def _adjust_script_paths(self, frontmatter: dict) -> dict: """ frontmatter = deepcopy(frontmatter) - for script_key in ("scripts", "agent_scripts"): - scripts = frontmatter.get(script_key) - if not isinstance(scripts, dict): - continue - + scripts = frontmatter.get("scripts") + if isinstance(scripts, dict): for key, script_path in scripts.items(): if isinstance(script_path, str): scripts[key] = self.rewrite_project_relative_paths(script_path) @@ -333,11 +330,8 @@ def resolve_skill_placeholders( frontmatter = {} scripts = frontmatter.get("scripts", {}) or {} - agent_scripts = frontmatter.get("agent_scripts", {}) or {} if not isinstance(scripts, dict): scripts = {} - if not isinstance(agent_scripts, dict): - agent_scripts = {} init_opts = load_init_options(project_root) if not isinstance(init_opts, dict): @@ -351,17 +345,14 @@ def resolve_skill_placeholders( ) secondary_variant = "sh" if default_variant == "ps" else "ps" - if default_variant in scripts or default_variant in agent_scripts: + if default_variant in scripts: fallback_order.append(default_variant) - if secondary_variant in scripts or secondary_variant in agent_scripts: + if secondary_variant in scripts: fallback_order.append(secondary_variant) for key in scripts: if key not in fallback_order: fallback_order.append(key) - for key in agent_scripts: - if key not in fallback_order: - fallback_order.append(key) script_variant = fallback_order[0] if fallback_order else None @@ -370,14 +361,12 @@ def resolve_skill_placeholders( script_command = script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{SCRIPT}", script_command) - agent_script_command = ( - agent_scripts.get(script_variant) if script_variant else None - ) - if agent_script_command: - agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS") - body = body.replace("{AGENT_SCRIPT}", agent_script_command) - body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) + + # Resolve __CONTEXT_FILE__ from init-options + context_file = init_opts.get("context_file") or "" + body = body.replace("__CONTEXT_FILE__", context_file) + return CommandRegistrar.rewrite_project_relative_paths(body) def _convert_argument_placeholder( diff --git a/src/specify_cli/integrations/agy/scripts/update-context.ps1 b/src/specify_cli/integrations/agy/scripts/update-context.ps1 deleted file mode 100644 index 9eeb461657..0000000000 --- a/src/specify_cli/integrations/agy/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Antigravity (agy) integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy diff --git a/src/specify_cli/integrations/agy/scripts/update-context.sh b/src/specify_cli/integrations/agy/scripts/update-context.sh deleted file mode 100755 index d7303f6197..0000000000 --- a/src/specify_cli/integrations/agy/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Antigravity (agy) integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" agy diff --git a/src/specify_cli/integrations/amp/scripts/update-context.ps1 b/src/specify_cli/integrations/amp/scripts/update-context.ps1 deleted file mode 100644 index c217b99f9a..0000000000 --- a/src/specify_cli/integrations/amp/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Amp integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType amp diff --git a/src/specify_cli/integrations/amp/scripts/update-context.sh b/src/specify_cli/integrations/amp/scripts/update-context.sh deleted file mode 100755 index 56cbf6e787..0000000000 --- a/src/specify_cli/integrations/amp/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Amp integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" amp diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.ps1 b/src/specify_cli/integrations/auggie/scripts/update-context.ps1 deleted file mode 100644 index 49e7e6b5f3..0000000000 --- a/src/specify_cli/integrations/auggie/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Auggie CLI integration: create/update .augment/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType auggie diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.sh b/src/specify_cli/integrations/auggie/scripts/update-context.sh deleted file mode 100755 index 4cf80bba2b..0000000000 --- a/src/specify_cli/integrations/auggie/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Auggie CLI integration: create/update .augment/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" auggie diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 2c01e25b0e..4c71b165e5 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -84,6 +84,11 @@ class IntegrationBase(ABC): context_file: str | None = None """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" + # -- Markers for managed context section ------------------------------ + + CONTEXT_MARKER_START = "" + CONTEXT_MARKER_END = "" + # -- Public API ------------------------------------------------------- @classmethod @@ -380,22 +385,235 @@ def install_scripts( return created + # -- Agent context file management ------------------------------------ + + @staticmethod + def _ensure_mdc_frontmatter(content: str) -> str: + """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. + + If frontmatter is missing, prepend it. If frontmatter exists but + ``alwaysApply`` is absent or not ``true``, inject/fix it. + + Uses string/regex manipulation to preserve comments and formatting + in existing frontmatter. + """ + import re as _re + + leading_ws = len(content) - len(content.lstrip()) + leading = content[:leading_ws] + stripped = content[leading_ws:] + + if not stripped.startswith("---"): + return "---\nalwaysApply: true\n---\n\n" + content + + # Match frontmatter block: ---\n...\n--- + match = _re.match( + r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)", + stripped, + _re.DOTALL, + ) + if not match: + return "---\nalwaysApply: true\n---\n\n" + content + + opening, fm_text, closing, sep, rest = match.groups() + newline = "\r\n" if "\r\n" in opening else "\n" + + # Already correct? + if _re.search( + r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text + ): + return content + + # alwaysApply exists but wrong value — fix in place while preserving + # indentation and any trailing inline comment. + if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): + fm_text = _re.sub( + r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$", + r"\1alwaysApply: true\2", + fm_text, + count=1, + ) + elif fm_text.strip(): + fm_text = fm_text + newline + "alwaysApply: true" + else: + fm_text = "alwaysApply: true" + + return f"{leading}{opening}{fm_text}{closing}{sep}{rest}" + + @staticmethod + def _build_context_section(plan_path: str = "") -> str: + """Build the content for the managed section between markers. + + *plan_path* is the project-relative path to the current plan + (e.g. ``"specs//plan.md"``). When empty, the section + contains only the generic directive without a concrete path. + """ + lines = [ + "For additional context about technologies to be used, project structure,", + "shell commands, and other important information, read the current plan", + ] + if plan_path: + lines.append(f"at {plan_path}") + return "\n".join(lines) + + def upsert_context_section( + self, + project_root: Path, + plan_path: str = "", + ) -> Path | None: + """Create or update the managed section in the agent context file. + + If the context file does not exist it is created with just the + managed section. If it exists, the content between + ```` and ```` markers + is replaced (or appended when no markers are found). + + Returns the path to the context file, or ``None`` when + ``context_file`` is not set. + """ + if not self.context_file: + return None + + ctx_path = project_root / self.context_file + section = ( + f"{self.CONTEXT_MARKER_START}\n" + f"{self._build_context_section(plan_path)}\n" + f"{self.CONTEXT_MARKER_END}\n" + ) + + if ctx_path.exists(): + content = ctx_path.read_text(encoding="utf-8") + start_idx = content.find(self.CONTEXT_MARKER_START) + end_idx = content.find( + self.CONTEXT_MARKER_END, + start_idx if start_idx != -1 else 0, + ) + + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + # Replace existing section (include the end marker + newline) + end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + # Consume trailing line ending (CRLF or LF) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = content[:start_idx] + section + content[end_of_marker:] + elif start_idx != -1: + # Corrupted: start marker without end — replace from start through EOF + new_content = content[:start_idx] + section + elif end_idx != -1: + # Corrupted: end marker without start — replace BOF through end marker + end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = section + content[end_of_marker:] + else: + # No markers found — append + if content: + if not content.endswith("\n"): + content += "\n" + new_content = content + "\n" + section + else: + new_content = section + + # Ensure .mdc files have required YAML frontmatter + if ctx_path.suffix == ".mdc": + new_content = self._ensure_mdc_frontmatter(new_content) + else: + ctx_path.parent.mkdir(parents=True, exist_ok=True) + # Cursor .mdc files require YAML frontmatter to be loaded + if ctx_path.suffix == ".mdc": + new_content = self._ensure_mdc_frontmatter(section) + else: + new_content = section + + normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") + ctx_path.write_bytes(normalized.encode("utf-8")) + return ctx_path + + def remove_context_section(self, project_root: Path) -> bool: + """Remove the managed section from the agent context file. + + Returns ``True`` if the section was found and removed. If the + file becomes empty (or whitespace-only) after removal it is + deleted. + """ + if not self.context_file: + return False + + ctx_path = project_root / self.context_file + if not ctx_path.exists(): + return False + + content = ctx_path.read_text(encoding="utf-8") + start_idx = content.find(self.CONTEXT_MARKER_START) + end_idx = content.find( + self.CONTEXT_MARKER_END, + start_idx if start_idx != -1 else 0, + ) + + # Only remove a complete, well-ordered managed section. If either + # marker is missing, leave the file unchanged to avoid deleting + # unrelated user-authored content. + if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: + return False + + removal_start = start_idx + removal_end = end_idx + len(self.CONTEXT_MARKER_END) + + # Consume trailing line ending (CRLF or LF) + if removal_end < len(content) and content[removal_end] == "\r": + removal_end += 1 + if removal_end < len(content) and content[removal_end] == "\n": + removal_end += 1 + + # Also strip a blank line before the section if present + if removal_start > 0 and content[removal_start - 1] == "\n": + if removal_start > 1 and content[removal_start - 2] == "\n": + removal_start -= 1 + + new_content = content[:removal_start] + content[removal_end:] + + # Normalize line endings before comparisons + normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") + + # For .mdc files, treat Speckit-generated frontmatter-only content as empty + if ctx_path.suffix == ".mdc": + import re + # Delete the file if only YAML frontmatter remains (no body content) + frontmatter_only = re.match( + r"^---\n.*?\n---\s*$", normalized, re.DOTALL + ) + if not normalized.strip() or frontmatter_only: + ctx_path.unlink() + return True + + if not normalized.strip(): + ctx_path.unlink() + else: + ctx_path.write_bytes(normalized.encode("utf-8")) + + return True + @staticmethod def process_template( content: str, agent_name: str, script_type: str, arg_placeholder: str = "$ARGUMENTS", + context_file: str = "", ) -> str: """Process a raw command template into agent-ready content. Performs the same transformations as the release script: 1. Extract ``scripts.`` value from YAML frontmatter 2. Replace ``{SCRIPT}`` with the extracted script command - 3. Extract ``agent_scripts.`` and replace ``{AGENT_SCRIPT}`` - 4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter - 5. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* - 6. Replace ``__AGENT__`` with *agent_name* + 3. Strip ``scripts:`` section from frontmatter + 4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* + 5. Replace ``__AGENT__`` with *agent_name* + 6. Replace ``__CONTEXT_FILE__`` with *context_file* 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. """ # 1. Extract script command from frontmatter @@ -421,25 +639,7 @@ def process_template( if script_command: content = content.replace("{SCRIPT}", script_command) - # 3. Extract agent_script command - agent_script_command = "" - in_agent_scripts = False - for line in content.splitlines(): - if line.strip() == "agent_scripts:": - in_agent_scripts = True - continue - if in_agent_scripts and line and not line[0].isspace(): - in_agent_scripts = False - if in_agent_scripts: - m = script_pattern.match(line) - if m: - agent_script_command = m.group(1).strip() - break - - if agent_script_command: - content = content.replace("{AGENT_SCRIPT}", agent_script_command) - - # 4. Strip scripts: and agent_scripts: sections from frontmatter + # 3. Strip scripts: section from frontmatter lines = content.splitlines(keepends=True) output_lines: list[str] = [] in_frontmatter = False @@ -457,23 +657,26 @@ def process_template( output_lines.append(line) continue if in_frontmatter: - if stripped in ("scripts:", "agent_scripts:"): + if stripped == "scripts:": skip_section = True continue if skip_section: if line[0:1].isspace(): - continue # skip indented content under scripts/agent_scripts + continue # skip indented content under scripts skip_section = False output_lines.append(line) content = "".join(output_lines) - # 5. Replace {ARGS} and $ARGUMENTS + # 4. Replace {ARGS} and $ARGUMENTS content = content.replace("{ARGS}", arg_placeholder) content = content.replace("$ARGUMENTS", arg_placeholder) - # 6. Replace __AGENT__ + # 5. Replace __AGENT__ content = content.replace("__AGENT__", agent_name) + # 6. Replace __CONTEXT_FILE__ + content = content.replace("__CONTEXT_FILE__", context_file) + # 7. Rewrite paths — delegate to the shared implementation in # CommandRegistrar so extension-local paths are preserved and # boundary rules stay consistent across the codebase. @@ -526,6 +729,9 @@ def setup( self.record_file_in_manifest(dst_file, project_root, manifest) created.append(dst_file) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created def teardown( @@ -539,9 +745,11 @@ def teardown( Delegates to ``manifest.uninstall()`` which only removes files whose hash still matches the recorded value (unless *force*). + Also removes the managed context section from the agent file. Returns ``(removed, skipped)`` file lists. """ + self.remove_context_section(project_root) return manifest.uninstall(project_root, force=force) # -- Convenience helpers for subclasses ------------------------------- @@ -579,8 +787,8 @@ class MarkdownIntegration(IntegrationBase): (and optionally ``context_file``). Everything else is inherited. ``setup()`` processes command templates (replacing ``{SCRIPT}``, - ``{ARGS}``, ``__AGENT__``, rewriting paths) and installs - integration-specific scripts (``update-context.sh`` / ``.ps1``). + ``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the + managed context section into the agent context file. """ def build_exec_args( @@ -638,7 +846,8 @@ def setup( for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -646,7 +855,9 @@ def setup( ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created @@ -841,7 +1052,8 @@ def setup( raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) processed = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) @@ -851,7 +1063,9 @@ def setup( ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created @@ -1021,7 +1235,8 @@ def setup( title = self._human_title(src_file.stem) processed = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) _, body = self._split_frontmatter(processed) yaml_content = self._render_yaml( @@ -1033,7 +1248,9 @@ def setup( ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created @@ -1176,7 +1393,8 @@ def setup( # Process body through the standard template pipeline processed_body = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) # Strip the processed frontmatter — we rebuild it for skills. # Preserve leading whitespace in the body to match release ZIP @@ -1220,5 +1438,7 @@ def _quote(v: str) -> str: ) created.append(dst) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created diff --git a/src/specify_cli/integrations/bob/scripts/update-context.ps1 b/src/specify_cli/integrations/bob/scripts/update-context.ps1 deleted file mode 100644 index 188860899f..0000000000 --- a/src/specify_cli/integrations/bob/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — IBM Bob integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType bob diff --git a/src/specify_cli/integrations/bob/scripts/update-context.sh b/src/specify_cli/integrations/bob/scripts/update-context.sh deleted file mode 100755 index 0228603fea..0000000000 --- a/src/specify_cli/integrations/bob/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — IBM Bob integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" bob diff --git a/src/specify_cli/integrations/claude/scripts/update-context.ps1 b/src/specify_cli/integrations/claude/scripts/update-context.ps1 deleted file mode 100644 index 837974d47a..0000000000 --- a/src/specify_cli/integrations/claude/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Claude Code integration: create/update CLAUDE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType claude diff --git a/src/specify_cli/integrations/claude/scripts/update-context.sh b/src/specify_cli/integrations/claude/scripts/update-context.sh deleted file mode 100755 index 4b83855a27..0000000000 --- a/src/specify_cli/integrations/claude/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Claude Code integration: create/update CLAUDE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" claude diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 b/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 deleted file mode 100644 index 0269392c09..0000000000 --- a/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — CodeBuddy integration: create/update CODEBUDDY.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codebuddy diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.sh b/src/specify_cli/integrations/codebuddy/scripts/update-context.sh deleted file mode 100755 index d57ddc3560..0000000000 --- a/src/specify_cli/integrations/codebuddy/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — CodeBuddy integration: create/update CODEBUDDY.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codebuddy diff --git a/src/specify_cli/integrations/codex/scripts/update-context.ps1 b/src/specify_cli/integrations/codex/scripts/update-context.ps1 deleted file mode 100644 index d73a5a4d34..0000000000 --- a/src/specify_cli/integrations/codex/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Codex CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codex diff --git a/src/specify_cli/integrations/codex/scripts/update-context.sh b/src/specify_cli/integrations/codex/scripts/update-context.sh deleted file mode 100755 index 512d6e91d3..0000000000 --- a/src/specify_cli/integrations/codex/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Codex CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codex diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index e389138a84..be7fc819f6 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -183,7 +183,10 @@ def setup( # 1. Process and write command files as .agent.md for src_file in templates: raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( processed, dest / dst_name, project_root, manifest @@ -217,8 +220,8 @@ def setup( self.record_file_in_manifest(dst_settings, project_root, manifest) created.append(dst_settings) - # 4. Install integration-specific update-context scripts - created.extend(self.install_scripts(project_root, manifest)) + # 4. Upsert managed context section into the agent context file + self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 b/src/specify_cli/integrations/copilot/scripts/update-context.ps1 deleted file mode 100644 index 26e746a789..0000000000 --- a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 +++ /dev/null @@ -1,32 +0,0 @@ -# update-context.ps1 — Copilot integration: create/update .github/copilot-instructions.md -# -# This is the copilot-specific implementation that produces the GitHub -# Copilot instructions file. The shared dispatcher reads -# .specify/integration.json and calls this script. -# -# NOTE: This script is not yet active. It will be activated in Stage 7 -# when the shared update-agent-context.ps1 replaces its switch statement -# with integration.json-based dispatch. The shared script must also be -# refactored to support SPECKIT_SOURCE_ONLY (guard the Main call) before -# dot-sourcing will work. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -# Invoke shared update-agent-context script as a separate process. -# Dot-sourcing is unsafe until that script guards its Main call. -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType copilot diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.sh b/src/specify_cli/integrations/copilot/scripts/update-context.sh deleted file mode 100644 index c7f3bc60b5..0000000000 --- a/src/specify_cli/integrations/copilot/scripts/update-context.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Copilot integration: create/update .github/copilot-instructions.md -# -# This is the copilot-specific implementation that produces the GitHub -# Copilot instructions file. The shared dispatcher reads -# .specify/integration.json and calls this script. -# -# NOTE: This script is not yet active. It will be activated in Stage 7 -# when the shared update-agent-context.sh replaces its case statement -# with integration.json-based dispatch. The shared script must also be -# refactored to support SPECKIT_SOURCE_ONLY (guard the main logic) -# before sourcing will work. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -# Invoke shared update-agent-context script as a separate process. -# Sourcing is unsafe until that script guards its main logic. -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" copilot diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 b/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 deleted file mode 100644 index 4ce50a4873..0000000000 --- a/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Cursor integration: create/update .cursor/rules/specify-rules.mdc -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType cursor-agent diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh b/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh deleted file mode 100755 index 597ca2289c..0000000000 --- a/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Cursor integration: create/update .cursor/rules/specify-rules.mdc -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" cursor-agent diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index e1c4d9da62..a941d4c331 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -130,7 +130,10 @@ def setup( for src_file in templates: raw = src_file.read_text(encoding="utf-8") # Process template with standard MarkdownIntegration logic - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) # FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are # converted to {{parameters}} @@ -145,8 +148,8 @@ def setup( ) created.append(dst_file) - # Install integration-specific update-context scripts - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/forge/scripts/update-context.ps1 b/src/specify_cli/integrations/forge/scripts/update-context.ps1 deleted file mode 100644 index 474a9c6d0b..0000000000 --- a/src/specify_cli/integrations/forge/scripts/update-context.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -# update-context.ps1 — Forge integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if (-not (Test-Path $sharedScript)) { - Write-Error "Error: shared agent context updater not found: $sharedScript" - Write-Error "Forge integration requires support in scripts/powershell/update-agent-context.ps1." - exit 1 -} - -& $sharedScript -AgentType forge -exit $LASTEXITCODE diff --git a/src/specify_cli/integrations/forge/scripts/update-context.sh b/src/specify_cli/integrations/forge/scripts/update-context.sh deleted file mode 100755 index 2a5c46e1d1..0000000000 --- a/src/specify_cli/integrations/forge/scripts/update-context.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Forge integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if [ ! -x "$shared_script" ]; then - echo "Error: shared agent context updater not found or not executable:" >&2 - echo " $shared_script" >&2 - echo "Forge integration requires support in scripts/bash/update-agent-context.sh." >&2 - exit 1 -fi - -exec "$shared_script" forge diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 b/src/specify_cli/integrations/gemini/scripts/update-context.ps1 deleted file mode 100644 index 51c9e0bc83..0000000000 --- a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Gemini CLI integration: create/update GEMINI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType gemini diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.sh b/src/specify_cli/integrations/gemini/scripts/update-context.sh deleted file mode 100644 index c4e5003a55..0000000000 --- a/src/specify_cli/integrations/gemini/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Gemini CLI integration: create/update GEMINI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" gemini diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index 4107c48690..fdaee4ed04 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -31,7 +31,7 @@ class GenericIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = None + context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -122,12 +122,17 @@ def setup( for src_file in templates: raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( processed, dest / dst_name, project_root, manifest ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created diff --git a/src/specify_cli/integrations/generic/scripts/update-context.ps1 b/src/specify_cli/integrations/generic/scripts/update-context.ps1 deleted file mode 100644 index 2e9467f801..0000000000 --- a/src/specify_cli/integrations/generic/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Generic integration: create/update context file -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType generic diff --git a/src/specify_cli/integrations/generic/scripts/update-context.sh b/src/specify_cli/integrations/generic/scripts/update-context.sh deleted file mode 100755 index d8ad30a7b8..0000000000 --- a/src/specify_cli/integrations/generic/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Generic integration: create/update context file -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" generic diff --git a/src/specify_cli/integrations/goose/scripts/update-context.ps1 b/src/specify_cli/integrations/goose/scripts/update-context.ps1 deleted file mode 100644 index eeb31f6296..0000000000 --- a/src/specify_cli/integrations/goose/scripts/update-context.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -# update-context.ps1 — Goose integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if (-not (Test-Path $sharedScript)) { - Write-Error "Error: shared agent context updater not found: $sharedScript" - Write-Error "Goose integration requires support in scripts/powershell/update-agent-context.ps1." - exit 1 -} - -& $sharedScript -AgentType goose -exit $LASTEXITCODE diff --git a/src/specify_cli/integrations/goose/scripts/update-context.sh b/src/specify_cli/integrations/goose/scripts/update-context.sh deleted file mode 100755 index 759ae3045a..0000000000 --- a/src/specify_cli/integrations/goose/scripts/update-context.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Goose integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if [ ! -x "$shared_script" ]; then - echo "Error: shared agent context updater not found or not executable:" >&2 - echo " $shared_script" >&2 - echo "Goose integration requires support in scripts/bash/update-agent-context.sh." >&2 - exit 1 -fi - -exec "$shared_script" goose diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.ps1 b/src/specify_cli/integrations/iflow/scripts/update-context.ps1 deleted file mode 100644 index b502d4182a..0000000000 --- a/src/specify_cli/integrations/iflow/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — iFlow CLI integration: create/update IFLOW.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType iflow diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.sh b/src/specify_cli/integrations/iflow/scripts/update-context.sh deleted file mode 100755 index 5080402071..0000000000 --- a/src/specify_cli/integrations/iflow/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — iFlow CLI integration: create/update IFLOW.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" iflow diff --git a/src/specify_cli/integrations/junie/scripts/update-context.ps1 b/src/specify_cli/integrations/junie/scripts/update-context.ps1 deleted file mode 100644 index 5a32432132..0000000000 --- a/src/specify_cli/integrations/junie/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Junie integration: create/update .junie/AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType junie diff --git a/src/specify_cli/integrations/junie/scripts/update-context.sh b/src/specify_cli/integrations/junie/scripts/update-context.sh deleted file mode 100755 index f4c8ba6c0e..0000000000 --- a/src/specify_cli/integrations/junie/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Junie integration: create/update .junie/AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" junie diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 b/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 deleted file mode 100644 index d87e7ef59f..0000000000 --- a/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Kilo Code integration: create/update .kilocode/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kilocode diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.sh b/src/specify_cli/integrations/kilocode/scripts/update-context.sh deleted file mode 100755 index 132c0403f3..0000000000 --- a/src/specify_cli/integrations/kilocode/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kilo Code integration: create/update .kilocode/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kilocode diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 b/src/specify_cli/integrations/kimi/scripts/update-context.ps1 deleted file mode 100644 index aa6678d052..0000000000 --- a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Kimi Code integration: create/update KIMI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kimi diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.sh b/src/specify_cli/integrations/kimi/scripts/update-context.sh deleted file mode 100755 index 2f81bc2a48..0000000000 --- a/src/specify_cli/integrations/kimi/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kimi Code integration: create/update KIMI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kimi diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 b/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 deleted file mode 100644 index 7dd2b35fb7..0000000000 --- a/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Kiro CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kiro-cli diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh b/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh deleted file mode 100755 index fa258edc75..0000000000 --- a/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kiro CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kiro-cli diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.ps1 b/src/specify_cli/integrations/opencode/scripts/update-context.ps1 deleted file mode 100644 index 4bba02b455..0000000000 --- a/src/specify_cli/integrations/opencode/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — opencode integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType opencode diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.sh b/src/specify_cli/integrations/opencode/scripts/update-context.sh deleted file mode 100755 index 24c7e60251..0000000000 --- a/src/specify_cli/integrations/opencode/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — opencode integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" opencode diff --git a/src/specify_cli/integrations/pi/scripts/update-context.ps1 b/src/specify_cli/integrations/pi/scripts/update-context.ps1 deleted file mode 100644 index 6362118a5b..0000000000 --- a/src/specify_cli/integrations/pi/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Pi Coding Agent integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType pi diff --git a/src/specify_cli/integrations/pi/scripts/update-context.sh b/src/specify_cli/integrations/pi/scripts/update-context.sh deleted file mode 100755 index 1ad84c95a2..0000000000 --- a/src/specify_cli/integrations/pi/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Pi Coding Agent integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" pi diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 b/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 deleted file mode 100644 index 1fa007a168..0000000000 --- a/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Qoder CLI integration: create/update QODER.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qodercli diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.sh b/src/specify_cli/integrations/qodercli/scripts/update-context.sh deleted file mode 100755 index d371ad7952..0000000000 --- a/src/specify_cli/integrations/qodercli/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Qoder CLI integration: create/update QODER.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" qodercli diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.ps1 b/src/specify_cli/integrations/qwen/scripts/update-context.ps1 deleted file mode 100644 index 24e4c90fab..0000000000 --- a/src/specify_cli/integrations/qwen/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Qwen Code integration: create/update QWEN.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qwen diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.sh b/src/specify_cli/integrations/qwen/scripts/update-context.sh deleted file mode 100755 index d1c62eb161..0000000000 --- a/src/specify_cli/integrations/qwen/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Qwen Code integration: create/update QWEN.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" qwen diff --git a/src/specify_cli/integrations/roo/scripts/update-context.ps1 b/src/specify_cli/integrations/roo/scripts/update-context.ps1 deleted file mode 100644 index d1dec923ed..0000000000 --- a/src/specify_cli/integrations/roo/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Roo Code integration: create/update .roo/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType roo diff --git a/src/specify_cli/integrations/roo/scripts/update-context.sh b/src/specify_cli/integrations/roo/scripts/update-context.sh deleted file mode 100755 index 8fe255cb1b..0000000000 --- a/src/specify_cli/integrations/roo/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Roo Code integration: create/update .roo/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" roo diff --git a/src/specify_cli/integrations/shai/scripts/update-context.ps1 b/src/specify_cli/integrations/shai/scripts/update-context.ps1 deleted file mode 100644 index 2c621c76ac..0000000000 --- a/src/specify_cli/integrations/shai/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — SHAI integration: create/update SHAI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType shai diff --git a/src/specify_cli/integrations/shai/scripts/update-context.sh b/src/specify_cli/integrations/shai/scripts/update-context.sh deleted file mode 100755 index 093b9d1f76..0000000000 --- a/src/specify_cli/integrations/shai/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — SHAI integration: create/update SHAI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" shai diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 b/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 deleted file mode 100644 index 0ffb3a1649..0000000000 --- a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Tabnine CLI integration: create/update TABNINE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType tabnine diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.sh b/src/specify_cli/integrations/tabnine/scripts/update-context.sh deleted file mode 100644 index fe5050b6e9..0000000000 --- a/src/specify_cli/integrations/tabnine/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Tabnine CLI integration: create/update TABNINE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" tabnine diff --git a/src/specify_cli/integrations/trae/scripts/update-context.ps1 b/src/specify_cli/integrations/trae/scripts/update-context.ps1 deleted file mode 100644 index ae9a3d1cd0..0000000000 --- a/src/specify_cli/integrations/trae/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Trae integration: create/update .trae/rules/project_rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType trae diff --git a/src/specify_cli/integrations/trae/scripts/update-context.sh b/src/specify_cli/integrations/trae/scripts/update-context.sh deleted file mode 100755 index 32e5c16b29..0000000000 --- a/src/specify_cli/integrations/trae/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Trae integration: create/update .trae/rules/project_rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" trae diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.ps1 b/src/specify_cli/integrations/vibe/scripts/update-context.ps1 deleted file mode 100644 index d82ce3389c..0000000000 --- a/src/specify_cli/integrations/vibe/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Mistral Vibe integration: create/update .vibe/agents/specify-agents.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType vibe diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.sh b/src/specify_cli/integrations/vibe/scripts/update-context.sh deleted file mode 100755 index f924cdb896..0000000000 --- a/src/specify_cli/integrations/vibe/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Mistral Vibe integration: create/update .vibe/agents/specify-agents.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" vibe diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 b/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 deleted file mode 100644 index b5fe1d0c0a..0000000000 --- a/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Windsurf integration: create/update .windsurf/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType windsurf diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.sh b/src/specify_cli/integrations/windsurf/scripts/update-context.sh deleted file mode 100755 index b9a78d320e..0000000000 --- a/src/specify_cli/integrations/windsurf/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Windsurf integration: create/update .windsurf/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" windsurf diff --git a/templates/agent-file-template.md b/templates/agent-file-template.md deleted file mode 100644 index 4cc7fd6678..0000000000 --- a/templates/agent-file-template.md +++ /dev/null @@ -1,28 +0,0 @@ -# [PROJECT NAME] Development Guidelines - -Auto-generated from all feature plans. Last updated: [DATE] - -## Active Technologies - -[EXTRACTED FROM ALL PLAN.MD FILES] - -## Project Structure - -```text -[ACTUAL STRUCTURE FROM PLANS] -``` - -## Commands - -[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] - -## Code Style - -[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] - -## Recent Changes - -[LAST 3 FEATURES AND WHAT THEY ADDED] - - - diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 4f1e9ed295..04db94ffaa 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -11,9 +11,6 @@ handoffs: scripts: sh: scripts/bash/setup-plan.sh --json ps: scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: scripts/bash/update-agent-context.sh __AGENT__ - ps: scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- ## User Input @@ -145,15 +142,11 @@ You **MUST** consider the user input before proceeding (if not empty). - Skip if project is purely internal (build scripts, one-off tools, etc.) 3. **Agent context update**: - - Run `{AGENT_SCRIPT}` - - These scripts detect which AI agent is in use - - Update the appropriate agent-specific context file - - Add only new technology from current plan - - Preserve manual additions between markers + - Update the plan reference between the `` and `` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path) -**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file +**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file ## Key rules -- Use absolute paths +- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files - ERROR on gate failures or unresolved clarifications diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index bd73ccd664..04a91682e8 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -56,14 +56,19 @@ def test_integration_copilot_creates_files(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "copilot" - assert "scripts" in data - assert "update-context" in data["scripts"] opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) assert opts["integration"] == "copilot" + assert opts["context_file"] == ".github/copilot-instructions.md" assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() - assert (project / ".specify" / "integrations" / "copilot" / "scripts" / "update-context.sh").exists() + + # Context section should be upserted into the copilot instructions file + ctx_file = project / ".github" / "copilot-instructions.md" + assert ctx_file.exists() + ctx_content = ctx_file.read_text(encoding="utf-8") + assert "" in ctx_content + assert "" in ctx_content shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" assert shared_manifest.exists() diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 3700d35de5..f22bb298b4 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -99,7 +99,23 @@ def test_templates_are_processed(self, tmp_path): assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block" - assert "\nagent_scripts:\n" not in content, f"{f.name} has unstripped agent_scripts: block" + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) @@ -132,30 +148,35 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- - - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # -- Context section --------------------------------------------------- - def test_scripts_tracked_in_manifest(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + # Add user content around the section + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -203,6 +224,30 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*")) assert len(commands) > 0, f"No command files in {cmd_dir}" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ @@ -220,10 +265,6 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.md") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files files.append(f".specify/integration.json") files.append(f".specify/init-options.json") @@ -232,14 +273,14 @@ def _expected_files(self, script_variant: str) -> list[str]: if script_variant == "sh": for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", - "setup-plan.sh", "update-agent-context.sh"]: + "setup-plan.sh"]: files.append(f".specify/scripts/bash/{name}") else: for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", - "setup-plan.ps1", "update-agent-context.ps1"]: + "setup-plan.ps1"]: files.append(f".specify/scripts/powershell/{name}") - for name in ["agent-file-template.md", "checklist-template.md", + for name in ["checklist-template.md", "constitution-template.md", "plan-template.md", "spec-template.md", "tasks-template.md"]: files.append(f".specify/templates/{name}") @@ -248,6 +289,11 @@ def _expected_files(self, script_variant: str) -> list[str]: # Bundled workflow files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 72d32278ba..c8c152a84b 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -173,6 +173,23 @@ def test_skill_body_has_content(self, tmp_path): body = parts[2].strip() if len(parts) >= 3 else "" assert len(body) > 0, f"{f} has empty body" + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan skill must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md" + assert plan_file.exists(), f"Plan skill {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan skill should reference {i.context_file!r} but it was not found" + ) + assert "__CONTEXT_FILE__" not in content, ( + "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" + ) + def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) @@ -217,30 +234,34 @@ def test_pre_existing_skills_not_removed(self, tmp_path): assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" - # -- Scripts ---------------------------------------------------------- + # -- Context section --------------------------------------------------- - def test_setup_installs_update_context_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - def test_scripts_tracked_in_manifest(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - i.setup(tmp_path, m) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -286,6 +307,30 @@ def test_integration_flag_creates_files(self, tmp_path): skills_dir = i.skills_dest(project) assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- IntegrationOption ------------------------------------------------ def test_options_include_skills_flag(self): @@ -316,8 +361,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/init-options.json", ".specify/integration.json", f".specify/integrations/{self.KEY}.manifest.json", - f".specify/integrations/{self.KEY}/scripts/update-context.ps1", - f".specify/integrations/{self.KEY}/scripts/update-context.sh", ".specify/integrations/speckit.manifest.json", ".specify/memory/constitution.md", ] @@ -328,7 +371,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", - ".specify/scripts/bash/update-agent-context.sh", ] else: files += [ @@ -336,11 +378,9 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", - ".specify/scripts/powershell/update-agent-context.ps1", ] # Templates files += [ - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -352,6 +392,9 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/workflows/speckit/workflow.yml", ".specify/workflows/workflow-registry.json", ] + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index e80f9abc10..ca66b2123a 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -310,6 +310,23 @@ def test_toml_is_valid(self, tmp_path): raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key" + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) + def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) @@ -341,37 +358,34 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- - - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # -- Context section --------------------------------------------------- - def test_scripts_tracked_in_manifest(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = ( - tmp_path - / ".specify" - / "integrations" - / self.KEY - / "scripts" - / "update-context.sh" - ) - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -441,6 +455,30 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.toml")) assert len(commands) > 0, f"No command files in {cmd_dir}" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ @@ -465,10 +503,6 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.toml") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files files.append(".specify/integration.json") files.append(".specify/init-options.json") @@ -481,7 +515,6 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.sh", "create-new-feature.sh", "setup-plan.sh", - "update-agent-context.sh", ]: files.append(f".specify/scripts/bash/{name}") else: @@ -490,12 +523,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.ps1", "create-new-feature.ps1", "setup-plan.ps1", - "update-agent-context.ps1", ]: files.append(f".specify/scripts/powershell/{name}") for name in [ - "agent-file-template.md", "checklist-template.md", "constitution-template.md", "plan-template.md", @@ -508,6 +539,11 @@ def _expected_files(self, script_variant: str) -> list[str]: # Bundled workflow files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index e4c31b3c88..08f088576c 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -189,6 +189,23 @@ def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "scripts:" not in parsed["prompt"] assert "---" not in parsed["prompt"] + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) + def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) @@ -220,37 +237,34 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- - - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # -- Context section --------------------------------------------------- - def test_scripts_tracked_in_manifest(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = ( - tmp_path - / ".specify" - / "integrations" - / self.KEY - / "scripts" - / "update-context.sh" - ) - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -320,6 +334,30 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.yaml")) assert len(commands) > 0, f"No command files in {cmd_dir}" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ @@ -344,10 +382,6 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.yaml") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files files.append(".specify/integration.json") files.append(".specify/init-options.json") @@ -360,7 +394,6 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.sh", "create-new-feature.sh", "setup-plan.sh", - "update-agent-context.sh", ]: files.append(f".specify/scripts/bash/{name}") else: @@ -369,12 +402,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.ps1", "create-new-feature.ps1", "setup-plan.ps1", - "update-agent-context.ps1", ]: files.append(f".specify/scripts/powershell/{name}") for name in [ - "agent-file-template.md", "checklist-template.md", "constitution-template.md", "plan-template.md", @@ -387,6 +418,11 @@ def _expected_files(self, script_variant: str) -> list[str]: # Bundled workflow files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 3d0a14acdc..6d82a6c390 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -285,7 +285,7 @@ def test_clear_cache(self, tmp_path): "commands": [ {"name": "speckit.specify", "file": "templates/speckit.specify.md"}, ], - "scripts": ["update-context.sh"], + "scripts": [], }, } @@ -305,7 +305,7 @@ def test_valid_descriptor(self, tmp_path): assert desc.description == "Integration for My Agent" assert desc.requires_speckit_version == ">=0.6.0" assert len(desc.commands) == 1 - assert desc.scripts == ["update-context.sh"] + assert desc.scripts == [] def test_missing_schema_version(self, tmp_path): data = {**VALID_DESCRIPTOR} diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index d3b01097fc..153983dcf4 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -62,19 +62,17 @@ def test_setup_creates_skill_files(self, tmp_path): assert parsed["disable-model-invocation"] is False assert parsed["metadata"]["source"] == "templates/commands/plan.md" - def test_setup_installs_update_context_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) - created = integration.setup(tmp_path, manifest, script_type="sh") - - scripts_dir = tmp_path / ".specify" / "integrations" / "claude" / "scripts" - assert scripts_dir.is_dir() - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - tracked = {path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created} - assert ".specify/integrations/claude/scripts/update-context.sh" in tracked - assert ".specify/integrations/claude/scripts/update-context.ps1" in tracked + integration.setup(tmp_path, manifest, script_type="sh") + + ctx_path = tmp_path / integration.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 34a9d54945..642b1e5300 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -143,7 +143,20 @@ def test_templates_are_processed(self, tmp_path): assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__" assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}" assert "\nscripts:\n" not in content - assert "\nagent_scripts:\n" not in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference copilot's context file.""" + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + copilot.setup(tmp_path, m) + plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert copilot.context_file in content, ( + f"Plan command should reference {copilot.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration copilot --script sh.""" @@ -181,18 +194,15 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", + ".github/copilot-instructions.md", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", ".specify/integrations/speckit.manifest.json", - ".specify/integrations/copilot/scripts/update-context.ps1", - ".specify/integrations/copilot/scripts/update-context.sh", ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", - ".specify/scripts/bash/update-agent-context.sh", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -243,18 +253,15 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", + ".github/copilot-instructions.md", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", ".specify/integrations/speckit.manifest.json", - ".specify/integrations/copilot/scripts/update-context.ps1", - ".specify/integrations/copilot/scripts/update-context.sh", ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", - ".specify/scripts/powershell/update-agent-context.ps1", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 3384fdc14f..352a0475b5 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -1,5 +1,10 @@ """Tests for CursorAgentIntegration.""" +from pathlib import Path + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + from .test_integration_base_skills import SkillsIntegrationTests @@ -11,6 +16,81 @@ class TestCursorAgentIntegration(SkillsIntegrationTests): CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" +class TestCursorMdcFrontmatter: + """Verify .mdc frontmatter handling in upsert/remove context section.""" + + def _setup(self, tmp_path: Path): + i = get_integration("cursor-agent") + m = IntegrationManifest("cursor-agent", tmp_path) + return i, m + + def test_new_mdc_gets_frontmatter(self, tmp_path): + """A freshly created .mdc file includes alwaysApply: true.""" + i, m = self._setup(tmp_path) + i.setup(tmp_path, m) + ctx = (tmp_path / i.context_file).read_text(encoding="utf-8") + assert ctx.startswith("---\n") + assert "alwaysApply: true" in ctx + + def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path): + """An existing .mdc without frontmatter gets it added.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text("# User rules\n", encoding="utf-8") + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert content.lstrip().startswith("---") + assert "alwaysApply: true" in content + assert "# User rules" in content + + def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path): + """An existing .mdc with custom frontmatter is preserved.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text( + "---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n", + encoding="utf-8", + ) + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert "alwaysApply: true" in content + assert "customKey: hello" in content + assert "" in content + + def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path): + """An .mdc with alwaysApply: false gets corrected.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text( + "---\nalwaysApply: false\n---\n\n# Rules\n", + encoding="utf-8", + ) + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert "alwaysApply: true" in content + assert "alwaysApply: false" not in content + + def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path): + """Repeated upserts don't duplicate frontmatter.""" + i, m = self._setup(tmp_path) + i.upsert_context_section(tmp_path) + i.upsert_context_section(tmp_path) + content = (tmp_path / i.context_file).read_text(encoding="utf-8") + assert content.count("alwaysApply") == 1 + + def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path): + """Removing the section from a Speckit-only .mdc deletes the file.""" + i, m = self._setup(tmp_path) + i.upsert_context_section(tmp_path) + ctx_path = tmp_path / i.context_file + assert ctx_path.exists() + i.remove_context_section(tmp_path) + assert not ctx_path.exists() + + class TestCursorAgentAutoPromote: """--ai cursor-agent auto-promotes to integration path.""" diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 7affd0d160..613ede91c0 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -73,19 +73,16 @@ def test_setup_creates_md_files(self, tmp_path): for f in command_files: assert f.name.endswith(".md") - def test_setup_installs_update_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) - created = forge.setup(tmp_path, m) - script_files = [f for f in created if "scripts" in f.parts] - assert len(script_files) > 0 - sh_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.sh" - ps_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.ps1" - assert sh_script in created - assert ps_script in created - assert sh_script.exists() - assert ps_script.exists() + forge.setup(tmp_path, m) + ctx_path = tmp_path / forge.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content def test_all_created_files_tracked_in_manifest(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration @@ -159,7 +156,20 @@ def test_templates_are_processed(self, tmp_path): assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS" # Frontmatter sections should be stripped assert "\nscripts:\n" not in content - assert "\nagent_scripts:\n" not in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference forge's context file.""" + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert forge.context_file in content, ( + f"Plan command should reference {forge.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content def test_forge_specific_transformations(self, tmp_path): """Test Forge-specific processing: name injection and handoffs stripping.""" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 74034ef105..8ca32078b5 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -31,9 +31,9 @@ def test_config_requires_cli_false(self): i = get_integration("generic") assert i.config["requires_cli"] is False - def test_context_file_is_none(self): + def test_context_file_is_agents_md(self): i = get_integration("generic") - assert i.context_file is None + assert i.context_file == "AGENTS.md" # -- Options ---------------------------------------------------------- @@ -158,30 +158,31 @@ def test_different_commands_dirs(self, tmp_path): cmd_files = [f for f in created if "scripts" not in f.parts] assert len(cmd_files) > 0 - # -- Scripts ---------------------------------------------------------- + # -- Context section --------------------------------------------------- - def test_setup_installs_update_context_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts" - assert scripts_dir.is_dir(), "Scripts directory not created for generic" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - def test_scripts_tracked_in_manifest(self, tmp_path): - i = get_integration("generic") - m = IntegrationManifest("generic", tmp_path) - i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference generic's context file.""" i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content # -- CLI -------------------------------------------------------------- @@ -198,6 +199,28 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): # The integration path validates via setup() assert result.exit_code != 0 + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the generic integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "opts-generic" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--ai-commands-dir", ".myagent/commands", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + assert opts.get("context_file") == "AGENTS.md" + def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration generic --ai-commands-dir ... --script sh.""" from typer.testing import CliRunner @@ -221,6 +244,7 @@ def test_complete_file_inventory_sh(self, tmp_path): for p in project.rglob("*") if p.is_file() ) expected = sorted([ + "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -233,16 +257,12 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", - ".specify/integrations/generic/scripts/update-context.ps1", - ".specify/integrations/generic/scripts/update-context.sh", ".specify/integrations/speckit.manifest.json", ".specify/memory/constitution.md", ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", - ".specify/scripts/bash/update-agent-context.sh", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -279,6 +299,7 @@ def test_complete_file_inventory_ps(self, tmp_path): for p in project.rglob("*") if p.is_file() ) expected = sorted([ + "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -291,16 +312,12 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", - ".specify/integrations/generic/scripts/update-context.ps1", - ".specify/integrations/generic/scripts/update-context.sh", ".specify/integrations/speckit.manifest.json", ".specify/memory/constitution.md", ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", - ".specify/scripts/powershell/update-agent-context.ps1", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 9cfe1ddbc9..75e80fdf33 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -1,6 +1,5 @@ """Consistency checks for agent configuration across runtime surfaces.""" -import re from pathlib import Path from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP @@ -61,20 +60,6 @@ def test_devcontainer_kiro_installer_uses_pinned_checksum(self): assert "sha256sum -c -" in post_create_text assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text - def test_agent_context_scripts_use_kiro_cli(self): - """Agent context scripts should advertise kiro-cli and not legacy q agent key.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "kiro-cli" in bash_text - assert "kiro-cli" in pwsh_text - assert "Amazon Q Developer CLI" not in bash_text - assert "Amazon Q Developer CLI" not in pwsh_text - # --- Tabnine CLI consistency checks --- def test_runtime_config_includes_tabnine(self): @@ -96,20 +81,6 @@ def test_extension_registrar_includes_tabnine(self): assert cfg["args"] == "{{args}}" assert cfg["extension"] == ".toml" - def test_agent_context_scripts_include_tabnine(self): - """Agent context scripts should support tabnine agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "tabnine" in bash_text - assert "TABNINE_FILE" in bash_text - assert "tabnine" in pwsh_text - assert "TABNINE_FILE" in pwsh_text - def test_ai_help_includes_tabnine(self): """CLI help text for --ai should include tabnine.""" assert "tabnine" in AI_ASSISTANT_HELP @@ -132,18 +103,6 @@ def test_kimi_in_extension_registrar(self): assert kimi_cfg["dir"] == ".kimi/skills" assert kimi_cfg["extension"] == "/SKILL.md" - def test_kimi_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'kimi' in ValidateSet.""" - ps_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "kimi" in validate_set_values - def test_ai_help_includes_kimi(self): """CLI help text for --ai should include kimi.""" assert "kimi" in AI_ASSISTANT_HELP @@ -168,32 +127,6 @@ def test_trae_in_extension_registrar(self): assert trae_cfg["args"] == "$ARGUMENTS" assert trae_cfg["extension"] == "/SKILL.md" - def test_trae_in_agent_context_scripts(self): - """Agent context scripts should support trae agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "trae" in bash_text - assert "TRAE_FILE" in bash_text - assert "trae" in pwsh_text - assert "TRAE_FILE" in pwsh_text - - def test_trae_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'trae' in ValidateSet.""" - ps_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "trae" in validate_set_values - def test_ai_help_includes_trae(self): """CLI help text for --ai should include trae.""" assert "trae" in AI_ASSISTANT_HELP @@ -219,32 +152,6 @@ def test_pi_in_extension_registrar(self): assert pi_cfg["args"] == "$ARGUMENTS" assert pi_cfg["extension"] == ".md" - def test_pi_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'pi' in ValidateSet.""" - ps_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "pi" in validate_set_values - - def test_agent_context_scripts_include_pi(self): - """Agent context scripts should support pi agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "pi" in bash_text - assert "Pi Coding Agent" in bash_text - assert "pi" in pwsh_text - assert "Pi Coding Agent" in pwsh_text - def test_ai_help_includes_pi(self): """CLI help text for --ai should include pi.""" assert "pi" in AI_ASSISTANT_HELP @@ -267,20 +174,6 @@ def test_iflow_in_extension_registrar(self): assert cfg["iflow"]["format"] == "markdown" assert cfg["iflow"]["args"] == "$ARGUMENTS" - def test_iflow_in_agent_context_scripts(self): - """Agent context scripts should support iflow agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "iflow" in bash_text - assert "IFLOW_FILE" in bash_text - assert "iflow" in pwsh_text - assert "IFLOW_FILE" in pwsh_text - def test_ai_help_includes_iflow(self): """CLI help text for --ai should include iflow.""" assert "iflow" in AI_ASSISTANT_HELP @@ -303,18 +196,6 @@ def test_goose_in_extension_registrar(self): assert cfg["goose"]["format"] == "yaml" assert cfg["goose"]["args"] == "{{args}}" - def test_goose_in_agent_context_scripts(self): - """Agent context scripts should support goose agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "goose" in bash_text - assert "goose" in pwsh_text - def test_ai_help_includes_goose(self): """CLI help text for --ai should include goose.""" assert "goose" in AI_ASSISTANT_HELP diff --git a/tests/test_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py deleted file mode 100644 index 9f8c31ce10..0000000000 --- a/tests/test_cursor_frontmatter.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -Tests for Cursor .mdc frontmatter generation (issue #669). - -Verifies that update-agent-context.sh properly prepends YAML frontmatter -to .mdc files so that Cursor IDE auto-includes the rules. -""" - -import os -import shutil -import subprocess -import textwrap - -import pytest - -from tests.conftest import requires_bash - -SCRIPT_PATH = os.path.join( - os.path.dirname(__file__), - os.pardir, - "scripts", - "bash", - "update-agent-context.sh", -) - -EXPECTED_FRONTMATTER_LINES = [ - "---", - "description: Project Development Guidelines", - 'globs: ["**/*"]', - "alwaysApply: true", - "---", -] - -requires_git = pytest.mark.skipif( - shutil.which("git") is None, - reason="git is not installed", -) - - -class TestScriptFrontmatterPattern: - """Static analysis — no git required.""" - - def test_create_new_has_mdc_frontmatter_logic(self): - """create_new_agent_file() must contain .mdc frontmatter logic.""" - with open(SCRIPT_PATH, encoding="utf-8") as f: - content = f.read() - assert 'if [[ "$target_file" == *.mdc ]]' in content - assert "alwaysApply: true" in content - - def test_update_existing_has_mdc_frontmatter_logic(self): - """update_existing_agent_file() must also handle .mdc frontmatter.""" - with open(SCRIPT_PATH, encoding="utf-8") as f: - content = f.read() - # There should be two occurrences of the .mdc check — one per function - occurrences = content.count('if [[ "$target_file" == *.mdc ]]') - assert occurrences >= 2, ( - f"Expected at least 2 .mdc frontmatter checks, found {occurrences}" - ) - - def test_powershell_script_has_mdc_frontmatter_logic(self): - """PowerShell script must also handle .mdc frontmatter.""" - ps_path = os.path.join( - os.path.dirname(__file__), - os.pardir, - "scripts", - "powershell", - "update-agent-context.ps1", - ) - with open(ps_path, encoding="utf-8") as f: - content = f.read() - assert "alwaysApply: true" in content - occurrences = content.count(r"\.mdc$") - assert occurrences >= 2, ( - f"Expected at least 2 .mdc frontmatter checks in PS script, found {occurrences}" - ) - - -@requires_git -@requires_bash -class TestCursorFrontmatterIntegration: - """Integration tests using a real git repo.""" - - @pytest.fixture - def git_repo(self, tmp_path): - """Create a minimal git repo with the spec-kit structure.""" - repo = tmp_path / "repo" - repo.mkdir() - - # Init git repo - subprocess.run( - ["git", "init"], cwd=str(repo), capture_output=True, check=True - ) - subprocess.run( - ["git", "config", "user.email", "test@test.com"], - cwd=str(repo), - capture_output=True, - check=True, - ) - subprocess.run( - ["git", "config", "user.name", "Test"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create .specify dir with config - specify_dir = repo / ".specify" - specify_dir.mkdir() - (specify_dir / "config.yaml").write_text( - textwrap.dedent("""\ - project_type: webapp - language: python - framework: fastapi - database: N/A - """) - ) - - # Create template - templates_dir = specify_dir / "templates" - templates_dir.mkdir() - (templates_dir / "agent-file-template.md").write_text( - "# [PROJECT NAME] Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: [DATE]\n\n" - "## Active Technologies\n\n" - "[EXTRACTED FROM ALL PLAN.MD FILES]\n\n" - "## Project Structure\n\n" - "[ACTUAL STRUCTURE FROM PLANS]\n\n" - "## Development Commands\n\n" - "[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]\n\n" - "## Coding Conventions\n\n" - "[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]\n\n" - "## Recent Changes\n\n" - "[LAST 3 FEATURES AND WHAT THEY ADDED]\n" - ) - - # Create initial commit - subprocess.run( - ["git", "add", "-A"], cwd=str(repo), capture_output=True, check=True - ) - subprocess.run( - ["git", "commit", "-m", "init"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create a feature branch so CURRENT_BRANCH detection works - subprocess.run( - ["git", "checkout", "-b", "001-test-feature"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create a spec so the script detects the feature - spec_dir = repo / "specs" / "001-test-feature" - spec_dir.mkdir(parents=True) - (spec_dir / "plan.md").write_text( - "# Test Feature Plan\n\n" - "## Technology Stack\n\n" - "- Language: Python\n" - "- Framework: FastAPI\n" - ) - - return repo - - def _run_update(self, repo, agent_type="cursor-agent"): - """Run update-agent-context.sh for a specific agent type.""" - script = os.path.abspath(SCRIPT_PATH) - result = subprocess.run( - ["bash", script, agent_type], - cwd=str(repo), - capture_output=True, - text=True, - timeout=30, - ) - return result - - def test_new_mdc_file_has_frontmatter(self, git_repo): - """Creating a new .mdc file must include YAML frontmatter.""" - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - mdc_file = git_repo / ".cursor" / "rules" / "specify-rules.mdc" - assert mdc_file.exists(), "Cursor .mdc file was not created" - - content = mdc_file.read_text() - lines = content.splitlines() - - # First line must be the opening --- - assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" - - # Check all frontmatter lines are present - for expected in EXPECTED_FRONTMATTER_LINES: - assert expected in content, f"Missing frontmatter line: {expected}" - - # Content after frontmatter should be the template content - assert "Development Guidelines" in content - - def test_existing_mdc_without_frontmatter_gets_it_added(self, git_repo): - """Updating an existing .mdc file that lacks frontmatter must add it.""" - # First, create the file WITHOUT frontmatter (simulating pre-fix state) - cursor_dir = git_repo / ".cursor" / "rules" - cursor_dir.mkdir(parents=True, exist_ok=True) - mdc_file = cursor_dir / "specify-rules.mdc" - mdc_file.write_text( - "# repo Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" - "## Active Technologies\n\n" - "- Python + FastAPI (main)\n\n" - "## Recent Changes\n\n" - "- main: Added Python + FastAPI\n" - ) - - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - content = mdc_file.read_text() - lines = content.splitlines() - - assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" - for expected in EXPECTED_FRONTMATTER_LINES: - assert expected in content, f"Missing frontmatter line: {expected}" - - def test_existing_mdc_with_frontmatter_not_duplicated(self, git_repo): - """Updating an .mdc file that already has frontmatter must not duplicate it.""" - cursor_dir = git_repo / ".cursor" / "rules" - cursor_dir.mkdir(parents=True, exist_ok=True) - mdc_file = cursor_dir / "specify-rules.mdc" - - frontmatter = ( - "---\n" - "description: Project Development Guidelines\n" - 'globs: ["**/*"]\n' - "alwaysApply: true\n" - "---\n\n" - ) - body = ( - "# repo Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" - "## Active Technologies\n\n" - "- Python + FastAPI (main)\n\n" - "## Recent Changes\n\n" - "- main: Added Python + FastAPI\n" - ) - mdc_file.write_text(frontmatter + body) - - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - content = mdc_file.read_text() - # Count occurrences of the frontmatter delimiter - assert content.count("alwaysApply: true") == 1, ( - "Frontmatter was duplicated" - ) - - def test_non_mdc_file_has_no_frontmatter(self, git_repo): - """Non-.mdc agent files (e.g., Claude) must NOT get frontmatter.""" - result = self._run_update(git_repo, agent_type="claude") - assert result.returncode == 0, f"Script failed: {result.stderr}" - - claude_file = git_repo / ".claude" / "CLAUDE.md" - if claude_file.exists(): - content = claude_file.read_text() - assert not content.startswith("---"), ( - "Non-mdc file should not have frontmatter" - ) diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index c9d13382ab..89e8b4a8b8 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -396,11 +396,8 @@ def test_skill_registration_resolves_script_placeholders(self, project_dir, temp "description: Scripted plan command\n" "scripts:\n" " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n" - "agent_scripts:\n" - " sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n" "---\n\n" "Run {SCRIPT}\n" - "Then {AGENT_SCRIPT}\n" "Review templates/checklist.md and memory/constitution.md for __AGENT__.\n" ) @@ -409,11 +406,9 @@ def test_skill_registration_resolves_script_placeholders(self, project_dir, temp content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content assert "{ARGS}" not in content assert "__AGENT__" not in content assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh claude" in content assert ".specify/templates/checklist.md" in content assert ".specify/memory/constitution.md" in content diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 460404d597..5379178afe 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1334,13 +1334,9 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} Agent __AGENT__ """ ) @@ -1361,11 +1357,9 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir content = skill_file.read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content assert "__AGENT__" not in content assert "{ARGS}" not in content assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh codex" in content def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir): """Codex alias skills should render their own matching `name:` frontmatter.""" @@ -1451,13 +1445,9 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} """ ) @@ -1474,13 +1464,10 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti content = skill_file.read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content if platform.system().lower().startswith("win"): assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content - assert ".specify/scripts/powershell/update-agent-context.ps1 codex" in content else: assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh codex" in content def test_codex_skill_registration_handles_non_dict_init_options( self, project_dir, temp_dir @@ -1577,13 +1564,9 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} """ ) @@ -1599,7 +1582,6 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( content = skill_file.read_text() assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content - assert ".specify/scripts/powershell/update-agent-context.ps1 -AgentType codex" in content assert ".specify/scripts/bash/setup-plan.sh" not in content def test_register_commands_for_copilot(self, extension_dir, project_dir): diff --git a/tests/test_presets.py b/tests/test_presets.py index b883d554b0..35c19bdd7f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1648,7 +1648,6 @@ def test_url_cache_expired(self, project_dir): "tasks-template", "checklist-template", "constitution-template", - "agent-file-template", ] @@ -2911,7 +2910,7 @@ def test_lean_command_files_exist(self): assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" def test_lean_commands_have_no_scripts(self): - """Verify lean commands have no scripts or agent_scripts in frontmatter.""" + """Verify lean commands have no scripts in frontmatter.""" from specify_cli.agents import CommandRegistrar for name in LEAN_COMMAND_NAMES: @@ -2919,7 +2918,6 @@ def test_lean_commands_have_no_scripts(self): content = cmd_path.read_text() frontmatter, _ = CommandRegistrar.parse_frontmatter(content) assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" - assert "agent_scripts" not in frontmatter, f"{name} should not have agent_scripts in frontmatter" def test_lean_commands_have_no_hooks(self): """Verify lean commands do not contain extension hook boilerplate.""" From c118c1c30f961921e41891df3318a2ccd2ceea54 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:33:38 -0500 Subject: [PATCH 074/184] chore: release 0.7.3, begin 0.7.4.dev0 development (#2263) * chore: bump version to 0.7.3 * chore: begin 0.7.4.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d2202f08..33cf9b5682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.7.3] - 2026-04-17 + +### Changed + +- fix: replace shell-based context updates with marker-based upsert (#2259) +- Add Community Friends page to docs site (#2261) +- Add Spec Scope extension to community catalog (#2172) +- docs: add Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace to README (#2250) +- fix: suppress CRLF warnings in auto-commit.ps1 (#2258) +- feat: register Blueprint in community catalog (#2252) +- preset: Update preset-fiction-book-writing to community catalog -> v1.5.0 (#2256) +- chore(deps): bump actions/upload-pages-artifact from 3 to 5 (#2251) +- fix: add reference/*.md to docfx content glob (#2248) +- chore: release 0.7.2, begin 0.7.3.dev0 development (#2247) + ## [0.7.2] - 2026-04-16 ### Changed diff --git a/pyproject.toml b/pyproject.toml index dae79b0f6a..3d95d623b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.3.dev0" +version = "0.7.4.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 25684220856e5745dbef9d6dba0076550e00a445 Mon Sep 17 00:00:00 2001 From: BachVQ <47909357+baveku@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:57:45 +0700 Subject: [PATCH 075/184] fix(integrations): migrate Antigravity (agy) layout to .agents/ and deprecate --skills (#2276) * refactor(agy): update storage directory from .agent to .agents * feat: update Antigravity integration to use .agents/ directory layout and add version compatibility warnings * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: remove deprecated --skills flag from AgyIntegration and update related test assertions * Update src/specify_cli/integrations/agy/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: update Antigravity integration requirement to v1.20.5 and remove obsolete tests * test: update skills directory path from .agent to .agents in preset restoration test * Update tests/integrations/test_integration_agy.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/integrations/test_integration_agy.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/agy/__init__.py | 43 ++++++++++++-------- tests/integrations/test_integration_agy.py | 26 ++++++++++-- tests/test_presets.py | 2 +- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 9cd522745e..d62bafad40 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -1,13 +1,18 @@ """Antigravity (agy) integration — skills-based agent. -Antigravity uses ``.agent/skills/speckit-/SKILL.md`` layout. -Explicit command support was deprecated in version 1.20.5; -``--skills`` defaults to ``True``. +Antigravity uses ``.agents/skills/speckit-/SKILL.md`` layout (enforced since v1.20.5). """ from __future__ import annotations -from ..base import IntegrationOption, SkillsIntegration +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from ..base import SkillsIntegration + +if TYPE_CHECKING: + from ..manifest import IntegrationManifest + class AgyIntegration(SkillsIntegration): @@ -16,26 +21,32 @@ class AgyIntegration(SkillsIntegration): key = "agy" config = { "name": "Antigravity", - "folder": ".agent/", + "folder": ".agents/", "commands_subdir": "skills", "install_url": None, "requires_cli": False, } registrar_config = { - "dir": ".agent/skills", + "dir": ".agents/skills", "format": "markdown", "args": "$ARGUMENTS", "extension": "/SKILL.md", } context_file = "AGENTS.md" - @classmethod - def options(cls) -> list[IntegrationOption]: - return [ - IntegrationOption( - "--skills", - is_flag=True, - default=True, - help="Install as agent skills (default for Antigravity since v1.20.5)", - ), - ] + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + import click + + click.secho( + "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer. " + "Please ensure your agy installation is up to date.", + fg="yellow", + err=True, + ) + return super().setup(project_root, manifest, parsed_options=parsed_options, **opts) diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index 21cb1d832e..b95caf3bee 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -5,12 +5,17 @@ class TestAgyIntegration(SkillsIntegrationTests): KEY = "agy" - FOLDER = ".agent/" + FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" - REGISTRAR_DIR = ".agent/skills" + REGISTRAR_DIR = ".agents/skills" CONTEXT_FILE = "AGENTS.md" - + def test_options_include_skills_flag(self): + """Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout.""" + from specify_cli.integrations import get_integration + i = get_integration(self.KEY) + skills_opts = [o for o in i.options() if o.name == "--skills"] + assert len(skills_opts) == 0 class TestAgyAutoPromote: """--ai agy auto-promotes to integration path.""" @@ -24,4 +29,17 @@ def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path): result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"]) assert result.exit_code == 0, f"init --ai agy failed: {result.output}" - assert (target / ".agent" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + + def test_agy_setup_warning(self, tmp_path): + """Agy integration should print a warning about v1.20.5 requirement during setup.""" + from typer.testing import CliRunner + from specify_cli import app + + # Click >= 8.2 separates stdout and stderr natively, mix_stderr is removed + runner = CliRunner() + target = tmp_path / "test-proj2" + result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh"]) + + assert result.exit_code == 0 + assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr diff --git a/tests/test_presets.py b/tests/test_presets.py index 35c19bdd7f..60322b99a1 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2447,7 +2447,7 @@ def test_kimi_preset_skill_override_resolves_script_placeholders(self, project_d def test_agy_skill_restored_on_preset_remove(self, project_dir, temp_dir): """Agy preset removal should restore native skills instead of deleting them.""" self._write_init_options(project_dir, ai="agy", ai_skills=True) - skills_dir = project_dir / ".agent" / "skills" + skills_dir = project_dir / ".agents" / "skills" self._create_skill(skills_dir, "speckit-specify", body="before override") core_command = project_dir / ".specify" / "templates" / "commands" / "specify.md" From dc057a231422bfb1b046436b8da65dfb9e187bd0 Mon Sep 17 00:00:00 2001 From: adaumann <94932945+adaumann@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:14:43 +0200 Subject: [PATCH 076/184] Preset fiction book writing1.6 (#2270) * Update preset-fiction-book-writing to community catalog - Preset ID: fiction-book-writing - Version: 1.5.0 - Author: Andreas Daumann - Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. V1.5.0: Support interactive, audiobooks, series, workflow corrections * Add fiction-book-writing preset to community catalog - Preset ID: fiction-book-writing - Version: 1.6.0 - Author: Andreas Daumann - Description: Added support for 12 languages, export with templates, cover builder, bio builder, workflow fixes * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed update_at for fiction-book-writing preset * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed description for fiction-book-writing --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- presets/catalog.community.json | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9f196bc123..0f3f3c8970 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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) | | 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) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | -| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations): features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose with 5 prose profiles. Supports interactive elements like brainstorming, interview, roleplay. | 21 templates, 26 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | 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) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index b25a5ee2ba..0e0194b27d 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -108,11 +108,11 @@ "fiction-book-writing": { "name": "Fiction Book Writing", "id": "fiction-book-writing", - "version": "1.5.0", - "description": "Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc.", + "version": "1.6.0", + "description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported.", "author": "Andreas Daumann", "repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing", - "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.5.0.zip", + "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.6.0.zip", "homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing", "documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md", "license": "MIT", @@ -120,8 +120,9 @@ "speckit_version": ">=0.5.0" }, "provides": { - "templates": 21, - "commands": 26 + "templates": 22, + "commands": 27, + "scripts": 1 }, "tags": [ "writing", @@ -135,11 +136,12 @@ "book", "brainstorming", "roleplay", - "audiobook" + "audiobook", + "language-support" ], "created_at": "2026-04-09T08:00:00Z", - "updated_at": "2026-04-16T08:00:00Z" - }, + "updated_at": "2026-04-19T08:00:00Z" + }, "multi-repo-branching": { "name": "Multi-Repo Branching", "id": "multi-repo-branching", From b4c4e86cbc65f108e403ef19898e35b68c6ebc72 Mon Sep 17 00:00:00 2001 From: Ayesha Khalid Date: Tue, 21 Apr 2026 02:00:20 +0500 Subject: [PATCH 077/184] fix(integrations): strip UTF-8 BOM when reading agent context files (#2283) * fix(integrations): strip UTF-8 BOM when reading agent context files * test(integrations): add BOM regression tests for context file read/write * test(workflows): mock shutil.which in tests that assume CLI is absent * test(integrations): remove unused manifest variable in BOM test --- src/specify_cli/integrations/base.py | 4 +- tests/integrations/test_integration_claude.py | 41 +++++++++++++++++++ tests/test_workflows.py | 16 ++++++-- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 4c71b165e5..a3d8a42aa2 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -482,7 +482,7 @@ def upsert_context_section( ) if ctx_path.exists(): - content = ctx_path.read_text(encoding="utf-8") + content = ctx_path.read_text(encoding="utf-8-sig") start_idx = content.find(self.CONTEXT_MARKER_START) end_idx = content.find( self.CONTEXT_MARKER_END, @@ -547,7 +547,7 @@ def remove_context_section(self, project_root: Path) -> bool: if not ctx_path.exists(): return False - content = ctx_path.read_text(encoding="utf-8") + content = ctx_path.read_text(encoding="utf-8-sig") start_idx = content.find(self.CONTEXT_MARKER_START) end_idx = content.find( self.CONTEXT_MARKER_END, diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index 153983dcf4..72e73bb02b 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,5 +1,6 @@ """Tests for ClaudeIntegration.""" +import codecs import json import os from unittest.mock import patch @@ -74,6 +75,46 @@ def test_setup_upserts_context_section(self, tmp_path): assert "" in content assert "read the current plan" in content + def test_upsert_context_section_strips_bom(self, tmp_path): + """Existing context file with UTF-8 BOM must be cleaned up on upsert.""" + integration = get_integration("claude") + ctx_path = tmp_path / integration.context_file + + # Write a file that starts with a UTF-8 BOM (as the old PowerShell script did) + bom = codecs.BOM_UTF8 + ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n") + + integration.upsert_context_section(tmp_path) + + result = ctx_path.read_bytes() + assert not result.startswith(bom), "BOM must be stripped after upsert" + content = result.decode("utf-8") + assert "" in content + assert "Some existing content." in content + + def test_remove_context_section_strips_bom(self, tmp_path): + """remove_context_section must clean BOM from context file on Windows-authored files.""" + integration = get_integration("claude") + ctx_path = tmp_path / integration.context_file + + marker_content = ( + "# CLAUDE.md\n\n" + "\n" + "For additional context about technologies to be used, project structure,\n" + "shell commands, and other important information, read the current plan\n" + "\n" + ) + ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8")) + + result = integration.remove_context_section(tmp_path) + + assert result is True + assert ctx_path.exists(), "File should exist (non-empty content remains)" + remaining = ctx_path.read_bytes() + assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove" + assert b" +## [0.7.4] - 2026-04-21 + +### Changed + +- fix(copilot): use --yolo to grant all permissions in non-interactive mode (#2298) +- feat: add CITATION.cff and .zenodo.json for academic citation support (#2291) +- Add spec-validate to community catalog (#2274) +- feat: register Ripple in community catalog (#2272) +- Add version-guard to community catalog (#2286) +- Add spec-reference-loader to community catalog (#2285) +- Add memory-loader to community catalog (#2284) +- fix(integrations): strip UTF-8 BOM when reading agent context files (#2283) +- Preset fiction book writing1.6 (#2270) +- fix(integrations): migrate Antigravity (agy) layout to .agents/ and deprecate --skills (#2276) +- chore: release 0.7.3, begin 0.7.4.dev0 development (#2263) + ## [0.7.3] - 2026-04-17 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 3d95d623b8..fad821feac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.4.dev0" +version = "0.7.5.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 569d18a59d945af5d3341aac3da29ad2a531e3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A4=80=ED=98=B8?= <101695482+chordpli@users.noreply.github.com> Date: Wed, 22 Apr 2026 02:06:09 +0900 Subject: [PATCH 086/184] fix(agents): block directory traversal in command write paths (#2229) (#2296) Extend the alias containment guard from b67b285 to the two remaining write paths that derive filenames from free-form command/alias names: - Primary command write in CommandRegistrar.register_commands() - CommandRegistrar.write_copilot_prompt() Consolidate the check into a shared _ensure_inside() helper. Per maintainer guidance on #2229, use a lexical (os.path.normpath + Path.is_relative_to) containment check rather than resolve() so `..` / absolute-path traversal is rejected while intentionally symlinked sub-directories under an agent's commands directory (e.g. .claude/skills/shared -> /team/shared-skills) keep working for existing extension setups. Add 22 parametrised regression cases covering traversal payloads on primary commands, aliases, and the Copilot companion prompt, plus a positive case that confirms symlinked sub-directories remain supported. --- src/specify_cli/agents.py | 32 +++- tests/test_registrar_path_traversal.py | 204 +++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 tests/test_registrar_path_traversal.py diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 1a0e5a8317..ef4e879c03 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -6,6 +6,7 @@ command files into agent-specific directories in the correct format. """ +import os from pathlib import Path from typing import Dict, List, Any @@ -399,6 +400,28 @@ def _compute_output_name( return f"speckit-{short_name}" + @staticmethod + def _ensure_inside(candidate: Path, base: Path) -> None: + """Validate that a write target stays within the expected base directory. + + Uses lexical normalization so traversal via ``..`` or absolute paths is + rejected while intentionally symlinked sub-directories remain + supported. + + Args: + candidate: Path that will be written. + base: Directory the write must remain within. + + Raises: + ValueError: If the normalized candidate path escapes ``base``. + """ + normalized = Path(os.path.normpath(candidate)) + base_normalized = Path(os.path.normpath(base)) + if not normalized.is_relative_to(base_normalized): + raise ValueError( + f"Output path {candidate!r} escapes directory {base!r}" + ) + def register_commands( self, agent_name: str, @@ -485,6 +508,7 @@ def register_commands( raise ValueError(f"Unsupported format: {agent_config['format']}") dest_file = commands_dir / f"{output_name}{agent_config['extension']}" + self._ensure_inside(dest_file, commands_dir) dest_file.parent.mkdir(parents=True, exist_ok=True) dest_file.write_text(output, encoding="utf-8") @@ -550,12 +574,7 @@ def register_commands( alias_file = ( commands_dir / f"{alias_output_name}{agent_config['extension']}" ) - try: - alias_file.resolve().relative_to(commands_dir.resolve()) - except ValueError: - raise ValueError( - f"Alias output path escapes commands directory: {alias_file!r}" - ) + self._ensure_inside(alias_file, commands_dir) alias_file.parent.mkdir(parents=True, exist_ok=True) alias_file.write_text(alias_output, encoding="utf-8") if agent_name == "copilot": @@ -575,6 +594,7 @@ def write_copilot_prompt(project_root: Path, cmd_name: str) -> None: prompts_dir = project_root / ".github" / "prompts" prompts_dir.mkdir(parents=True, exist_ok=True) prompt_file = prompts_dir / f"{cmd_name}.prompt.md" + CommandRegistrar._ensure_inside(prompt_file, prompts_dir) prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8") def register_commands_for_all_agents( diff --git a/tests/test_registrar_path_traversal.py b/tests/test_registrar_path_traversal.py new file mode 100644 index 0000000000..fc423b4056 --- /dev/null +++ b/tests/test_registrar_path_traversal.py @@ -0,0 +1,204 @@ +"""Tests for CommandRegistrar directory traversal guards around issue #2229.""" + +import errno +from pathlib import Path + +import pytest + +from specify_cli.agents import CommandRegistrar + + +TRAVERSAL_PAYLOADS = [ + "../pwned", + "../../etc/passwd", + "subdir/../../escape", + "/absolute/evil", +] + + +def _write_source(ext_dir: Path) -> Path: + ext_dir.mkdir(parents=True, exist_ok=True) + (ext_dir / "commands").mkdir(exist_ok=True) + (ext_dir / "commands" / "cmd.md").write_text( + "---\ndescription: test\n---\n\nbody\n", encoding="utf-8" + ) + return ext_dir + + +def _cmd(name: str, aliases: list[str] | None = None) -> dict[str, object]: + return { + "name": name, + "file": "commands/cmd.md", + "aliases": list(aliases or []), + } + + +def _project_and_source(tmp_path): + project = tmp_path / "project" + project.mkdir() + ext_dir = _write_source(tmp_path / "ext-src") + return project, ext_dir + + +def _assert_no_stray_files(tmp_root: Path, marker: str) -> None: + """Fail if a file matching ``marker`` exists outside the project tree.""" + stray = [ + p for p in tmp_root.rglob("*") + if p.is_file() and marker in p.name and "project" not in p.parts + ] + assert stray == [], ( + f"Traversal payload leaked files outside the project tree: {stray}" + ) + + +class TestPrimaryCommandTraversal: + """Primary command names must not escape the agent's commands directory.""" + + @pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS) + def test_gemini_rejects_traversal_in_primary_name(self, tmp_path, bad_name): + project, ext_dir = _project_and_source(tmp_path) + (project / ".gemini" / "commands").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "gemini", [_cmd(bad_name)], "myext", ext_dir, project + ) + + _assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", "")) + + @pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS) + def test_copilot_rejects_traversal_in_primary_name(self, tmp_path, bad_name): + project, ext_dir = _project_and_source(tmp_path) + (project / ".github" / "agents").mkdir(parents=True) + (project / ".github" / "prompts").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "copilot", [_cmd(bad_name)], "myext", ext_dir, project + ) + + _assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", "")) + + +class TestAliasTraversal: + """Free-form aliases must not escape commands_dir (regression for b67b285).""" + + @pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS) + def test_gemini_rejects_traversal_in_alias(self, tmp_path, bad_alias): + project, ext_dir = _project_and_source(tmp_path) + (project / ".gemini" / "commands").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "gemini", + [_cmd("speckit.myext.ok", [bad_alias])], + "myext", + ext_dir, + project, + ) + + _assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", "")) + + @pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS) + def test_copilot_rejects_traversal_in_alias(self, tmp_path, bad_alias): + project, ext_dir = _project_and_source(tmp_path) + (project / ".github" / "agents").mkdir(parents=True) + (project / ".github" / "prompts").mkdir(parents=True) + + registrar = CommandRegistrar() + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + registrar.register_commands( + "copilot", + [_cmd("speckit.myext.ok", [bad_alias])], + "myext", + ext_dir, + project, + ) + + _assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", "")) + + +class TestCopilotPromptTraversal: + """`write_copilot_prompt` is a public static method — guard it directly.""" + + @pytest.mark.parametrize("bad_name", TRAVERSAL_PAYLOADS) + def test_rejects_traversal_names(self, tmp_path, bad_name): + project = tmp_path / "project" + (project / ".github" / "prompts").mkdir(parents=True) + + with pytest.raises(ValueError, match="escapes|outside|Invalid"): + CommandRegistrar.write_copilot_prompt(project, bad_name) + + _assert_no_stray_files(tmp_path, Path(bad_name).name.replace("/", "")) + + +class TestSafeRegistration: + """Positive regression — well-formed names continue to register.""" + + def test_symlinked_subdir_under_commands_dir_is_preserved(self, tmp_path): + """Lexical check must not block legitimately symlinked sub-directories. + + Teams sometimes symlink shared skills into their agent commands dir + (e.g. ``.gemini/commands/shared -> /team/shared-commands``). The + guard is purely lexical, so such a setup continues to work even though + the resolved target lives outside commands_dir on disk. + """ + project, ext_dir = _project_and_source(tmp_path) + commands_dir = project / ".gemini" / "commands" + commands_dir.mkdir(parents=True) + + external_shared = tmp_path / "external-shared" + external_shared.mkdir() + try: + (commands_dir / "shared").symlink_to( + external_shared, target_is_directory=True + ) + except OSError as exc: + if exc.errno in {errno.EPERM, errno.EACCES}: + pytest.skip("symlink creation is not permitted in this environment") + raise + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "gemini", + [_cmd("shared/hello")], + "myext", + ext_dir, + project, + ) + + assert registered == ["shared/hello"] + assert (external_shared / "hello.toml").exists() + + def test_safe_command_and_alias_still_register(self, tmp_path): + project, ext_dir = _project_and_source(tmp_path) + (project / ".claude" / "skills").mkdir(parents=True) + + registrar = CommandRegistrar() + registered = registrar.register_commands( + "claude", + [_cmd("speckit.myext.hello", ["speckit.myext.hi"])], + "myext", + ext_dir, + project, + ) + + assert "speckit.myext.hello" in registered + assert "speckit.myext.hi" in registered + assert ( + project + / ".claude" + / "skills" + / "speckit-myext-hello" + / "SKILL.md" + ).exists() + assert ( + project + / ".claude" + / "skills" + / "speckit-myext-hi" + / "SKILL.md" + ).exists() From 22e76995c7526f92704ca5df5b2db7d3b84dfa0e Mon Sep 17 00:00:00 2001 From: Kennedy <116378813+kennedy-whytech@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:02:31 -0400 Subject: [PATCH 087/184] feat: implement preset wrap strategy (#2189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement strategy: wrap * fix: resolve merge conflict for strategy wrap correctness * feat: multi-preset composable wrapping with priority ordering Implements comment #4 from PR review: multiple installed wrap presets now compose in priority order rather than overwriting each other. Key changes: - PresetResolver.resolve() gains skip_presets flag; resolve_core() wraps it to skip tier 2, preventing accidental nesting during replay - _replay_wraps_for_command() recomposed all enabled wrap presets for a command in ascending priority order (innermost-first) after any install or remove - _replay_skill_override() keeps SKILL.md in sync with the recomposed command body for ai-skills-enabled projects - install_from_directory() detects strategy: wrap commands, stores wrap_commands in the registry entry, and calls replay after install - remove() reads wrap_commands before deletion, removes registry entry before rmtree so replay sees post-removal state, then replays remaining wraps or unregisters when none remain Tests: TestResolveCore (5), TestReplayWrapsForCommand (5), TestInstallRemoveWrapLifecycle (5), plus 2 skill/alias regression tests * fix: resolve extension commands via manifest file mapping PresetResolver.resolve_extension_command_via_manifest() consults each installed extension.yml to find the actual file declared for a command name, rather than assuming the file is named .md. This fixes _substitute_core_template for extensions like selftest where the manifest maps speckit.selftest.extension → commands/selftest.md. Resolution order in _substitute_core_template is now: 1. resolve_core(cmd_name) — project overrides win, then name-based lookup 2. resolve_extension_command_via_manifest(cmd_name) — manifest fallback 3. resolve_core(short_name) — core template short-name fallback Path traversal guard mirrors the containment check already present in ExtensionManager to reject absolute paths or paths escaping the extension root. * fix: add bundled core_pack as Priority 5 in PresetResolver.resolve() resolve_core() was returning None for built-in commands (implement, specify, etc.) because PresetResolver only checked .specify/templates/ commands/ (Priority 4), which is never populated for commands in a normal project. strategy:wrap presets rely on resolve_core() to fetch the {CORE_TEMPLATE} body, so the wrap was silently skipped and SKILL.md was never updated. Priority 5 now checks core_pack/commands/ (wheel install) or repo_root/templates/commands/ (source checkout), mirroring the pattern used by _locate_core_pack() elsewhere. Updated two tests whose assertions assumed resolve_core() always returned None when .specify/templates/commands/ was absent. * fix: harden preset wrap replay removal * fix: stabilize existing directory error output * fix: track outermost_pack_id from contributing preset; use Path.parts in tests - outermost_pack_id now updates alongside outermost_frontmatter inside the wrap loop, so it reflects the actual last contributing preset rather than always taking wrap_presets[0] (which may have been skipped) - Replace str(path) substring checks in TestResolveCore with Path.parts tuple comparisons for correct behaviour on Windows (CI runs windows-latest) * fix: guard against non-mapping YAML manifests; apply integration post-processing in replay - ExtensionManifest._load raises ValidationError for non-dict YAML roots instead of TypeError - PresetManager._replay_wraps_for_command calls integration.post_process_skill_content, matching _register_skills behaviour - PresetResolver skips extensions that raise OSError/TypeError/AttributeError on manifest load - Tests: non-mapping YAML, OSError manifest skip, and replay integration post-processing --- presets/README.md | 2 +- .../self-test/commands/speckit.wrap-test.md | 14 + presets/self-test/preset.yml | 5 + src/specify_cli/__init__.py | 3 +- src/specify_cli/agents.py | 17 +- src/specify_cli/extensions.py | 7 +- src/specify_cli/presets.py | 458 +++++- tests/integrations/test_cli.py | 2 +- tests/test_extensions.py | 8 + tests/test_presets.py | 1271 ++++++++++++++++- 10 files changed, 1771 insertions(+), 16 deletions(-) create mode 100644 presets/self-test/commands/speckit.wrap-test.md diff --git a/presets/README.md b/presets/README.md index dd3997b239..72751b4bfb 100644 --- a/presets/README.md +++ b/presets/README.md @@ -116,5 +116,5 @@ The following enhancements are under consideration for future releases: | **command** | ✓ (default) | ✓ | ✓ | ✓ | | **script** | ✓ (default) | — | — | ✓ | - For artifacts and commands (which are LLM directives), `wrap` would inject preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder. For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable. + For artifacts and commands (which are LLM directives), `wrap` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented). - **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it. diff --git a/presets/self-test/commands/speckit.wrap-test.md b/presets/self-test/commands/speckit.wrap-test.md new file mode 100644 index 0000000000..78ace30ea8 --- /dev/null +++ b/presets/self-test/commands/speckit.wrap-test.md @@ -0,0 +1,14 @@ +--- +description: "Self-test wrap command — pre/post around core" +strategy: wrap +--- + +## Preset Pre-Logic + +preset:self-test wrap-pre + +{CORE_TEMPLATE} + +## Preset Post-Logic + +preset:self-test wrap-post diff --git a/presets/self-test/preset.yml b/presets/self-test/preset.yml index 82c7b068ad..8e718430aa 100644 --- a/presets/self-test/preset.yml +++ b/presets/self-test/preset.yml @@ -56,6 +56,11 @@ provides: description: "Self-test override of the specify command" replaces: "speckit.specify" + - type: "command" + name: "speckit.wrap-test" + file: "commands/speckit.wrap-test.md" + description: "Self-test wrap strategy command" + tags: - "testing" - "self-test" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8c6fd02b9f..97cb993a96 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1115,7 +1115,7 @@ def init( console.print(f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]") else: error_panel = Panel( - f"Directory '[cyan]{project_name}[/cyan]' already exists\n" + f"Directory already exists: '[cyan]{project_name}[/cyan]'\n" "Please choose a different project name or remove the existing directory.\n" "Use [bold]--force[/bold] to merge into the existing directory.", title="[red]Directory Conflict[/red]", @@ -1371,7 +1371,6 @@ def init( "branch_numbering": branch_numbering or "sequential", "context_file": resolved_integration.context_file, "here": here, - "preset": preset, "script": selected_script, "speckit_version": get_speckit_version(), } diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index ef4e879c03..c5e25a7085 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -468,6 +468,15 @@ def register_commands( content = source_file.read_text(encoding="utf-8") frontmatter, body = self.parse_frontmatter(content) + if frontmatter.get("strategy") == "wrap": + from .presets import _substitute_core_template + body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self) + frontmatter = dict(frontmatter) + for key in ("scripts", "agent_scripts"): + if key not in frontmatter and key in core_frontmatter: + frontmatter[key] = core_frontmatter[key] + frontmatter.pop("strategy", None) + frontmatter = self._adjust_script_paths(frontmatter) for key in agent_config.get("strip_frontmatter_keys", []): @@ -495,10 +504,12 @@ def register_commands( project_root, ) elif agent_config["format"] == "markdown": - output = self.render_markdown_command( - frontmatter, body, source_id, context_note - ) + body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) + body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"]) + output = self.render_markdown_command(frontmatter, body, source_id, context_note) elif agent_config["format"] == "toml": + body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) + body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"]) output = self.render_toml_command(frontmatter, body, source_id) elif agent_config["format"] == "yaml": output = self.render_yaml_command( diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index d5543cd0b4..26ceab4034 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -140,11 +140,16 @@ def _load_yaml(self, path: Path) -> dict: """Load YAML file safely.""" try: with open(path, 'r') as f: - return yaml.safe_load(f) or {} + data = yaml.safe_load(f) except yaml.YAMLError as e: raise ValidationError(f"Invalid YAML in {path}: {e}") except FileNotFoundError: raise ValidationError(f"Manifest not found: {path}") + if not isinstance(data, dict): + raise ValidationError( + f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}" + ) + return data def _validate(self): """Validate manifest structure and required fields.""" diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index d5513c8323..5f28be7204 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -16,7 +16,10 @@ import shutil from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, List, Any +from typing import TYPE_CHECKING, Optional, Dict, List, Any + +if TYPE_CHECKING: + from .agents import CommandRegistrar from datetime import datetime, timezone import re @@ -27,6 +30,59 @@ from .extensions import ExtensionRegistry, normalize_priority +def _substitute_core_template( + body: str, + cmd_name: str, + project_root: "Path", + registrar: "CommandRegistrar", +) -> "tuple[str, dict]": + """Substitute {CORE_TEMPLATE} with the body of the installed core command template. + + Args: + body: Preset command body (may contain {CORE_TEMPLATE} placeholder). + cmd_name: Full command name (e.g. "speckit.git.feature" or "speckit.specify"). + project_root: Project root path. + registrar: CommandRegistrar instance for parse_frontmatter. + + Returns: + A tuple of (body, core_frontmatter) where body has {CORE_TEMPLATE} replaced + by the core template body and core_frontmatter holds the core template's parsed + frontmatter (so callers can inherit scripts/agent_scripts from it). Both are + unchanged / empty when the placeholder is absent or the core template file does + not exist. + """ + if "{CORE_TEMPLATE}" not in body: + return body, {} + + # Derive the short name (strip "speckit." prefix) used by core command templates. + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + resolver = PresetResolver(project_root) + # Resolution order for the core template: + # 1. resolve_core(cmd_name) — covers tier-1 project overrides and tier-3/4 + # name-based lookup (file named .md). Checked first so that a + # local override always wins, even for extension commands. + # 2. resolve_extension_command_via_manifest(cmd_name) — manifest-based tier-3 + # fallback for extension commands whose file is named differently from the + # command name (e.g. speckit.selftest.extension → commands/selftest.md). + # 3. resolve_core(short_name) — core template fallback using the unprefixed + # name (e.g. specify → templates/commands/specify.md). + # resolve_core() skips installed presets (tier 2) to prevent accidental nesting + # where another preset's wrap output is mistaken for the real core. + core_file = ( + resolver.resolve_core(cmd_name, "command") + or resolver.resolve_extension_command_via_manifest(cmd_name) + or resolver.resolve_core(short_name, "command") + ) + if core_file is None: + return body, {} + + core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8")) + return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter + + @dataclass class PresetCatalogEntry: """Represents a single entry in the preset catalog stack.""" @@ -555,6 +611,232 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) + def _replay_wraps_for_command(self, cmd_name: str) -> None: + """Recompose and rewrite agent files for a wrap-strategy command. + + Collects all installed presets that declare cmd_name in their + wrap_commands registry field, sorts them so the highest-precedence + preset (lowest priority number) wraps outermost, then writes the + fully composed output to every agent directory. + + Called after every install and remove to keep agent files correct + regardless of installation order. + + Args: + cmd_name: Full command name (e.g. "speckit.specify") + """ + try: + from .agents import CommandRegistrar + except ImportError: + return + + # Collect enabled presets that wrap this command, sorted ascending + # (lowest priority number = highest precedence = outermost). + wrap_presets = [] + for pack_id, metadata in self.registry.list_by_priority(include_disabled=False): + if cmd_name not in metadata.get("wrap_commands", []): + continue + pack_dir = self.presets_dir / pack_id + if not pack_dir.is_dir(): + continue # corrupted state — skip + wrap_presets.append((pack_id, pack_dir)) + + if not wrap_presets: + return + + # Derive short name for core resolution fallback. + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + + resolver = PresetResolver(self.project_root) + core_file = ( + resolver.resolve_core(cmd_name, "command") + or resolver.resolve_extension_command_via_manifest(cmd_name) + or ( + resolver.resolve_extension_command_via_manifest(short_name) + if short_name != cmd_name + else None + ) + or resolver.resolve_core(short_name, "command") + ) + if core_file is None: + return + + registrar = CommandRegistrar() + core_frontmatter, core_body = registrar.parse_frontmatter( + core_file.read_text(encoding="utf-8") + ) + replay_aliases: List[str] = [] + seen_aliases: set[str] = set() + + # Apply wraps innermost-first (reverse of ascending list). + accumulated_body = core_body + outermost_frontmatter = {} + outermost_pack_id = wrap_presets[0][0] # fallback; updated per contributing preset + for pack_id, pack_dir in reversed(wrap_presets): + manifest_path = pack_dir / "preset.yml" + cmd_file: Optional[Path] = None + if manifest_path.exists(): + try: + manifest = PresetManifest(manifest_path) + except (PresetValidationError, KeyError, TypeError, ValueError): + manifest = None + if manifest is not None: + for template in manifest.templates: + if template.get("type") != "command" or template.get("name") != cmd_name: + continue + file_rel = template.get("file") + if isinstance(file_rel, str): + rel_path = Path(file_rel) + if not rel_path.is_absolute(): + try: + preset_root = pack_dir.resolve() + candidate = (preset_root / rel_path).resolve() + candidate.relative_to(preset_root) + except (OSError, ValueError): + candidate = None + if candidate is not None: + cmd_file = candidate + aliases = template.get("aliases", []) + if not isinstance(aliases, list): + aliases = [] + for alias in aliases: + if isinstance(alias, str) and alias not in seen_aliases: + replay_aliases.append(alias) + seen_aliases.add(alias) + break + if cmd_file is None: + cmd_file = pack_dir / "commands" / f"{cmd_name}.md" + if not cmd_file.exists(): + continue + wrap_fm, wrap_body = registrar.parse_frontmatter( + cmd_file.read_text(encoding="utf-8") + ) + accumulated_body = wrap_body.replace("{CORE_TEMPLATE}", accumulated_body) + outermost_frontmatter = wrap_fm # last iteration = outermost preset + outermost_pack_id = pack_id + + # Build final frontmatter: outermost preset wins; fall back to core for + # scripts/agent_scripts if the outermost preset does not define them. + final_frontmatter = dict(outermost_frontmatter) + final_frontmatter.pop("strategy", None) + for key in ("scripts", "agent_scripts"): + if key not in final_frontmatter and key in core_frontmatter: + final_frontmatter[key] = core_frontmatter[key] + + composed_content = ( + registrar.render_frontmatter(final_frontmatter) + "\n" + accumulated_body + ) + + self._replay_skill_override(cmd_name, composed_content, outermost_pack_id) + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + cmd_dir = tmp_path / "commands" + cmd_dir.mkdir() + (cmd_dir / f"{cmd_name}.md").write_text(composed_content, encoding="utf-8") + registrar._ensure_configs() + for agent_name, agent_config in registrar.AGENT_CONFIGS.items(): + if agent_config.get("extension") == "/SKILL.md": + continue + agent_dir = self.project_root / agent_config["dir"] + if not agent_dir.exists(): + continue + try: + registrar.register_commands( + agent_name, + [{ + "name": cmd_name, + "file": f"commands/{cmd_name}.md", + "aliases": replay_aliases, + }], + f"preset:{outermost_pack_id}", + tmp_path, + self.project_root, + ) + except ValueError: + continue + + def _replay_skill_override( + self, + cmd_name: str, + composed_content: str, + outermost_pack_id: str, + ) -> None: + """Rewrite any active SKILL.md override for a replayed wrap command.""" + skills_dir = self._get_skills_dir() + if not skills_dir: + return + + from . import SKILL_DESCRIPTIONS, load_init_options + from .agents import CommandRegistrar + from .integrations import get_integration + + init_opts = load_init_options(self.project_root) + if not isinstance(init_opts, dict): + init_opts = {} + selected_ai = init_opts.get("ai") + if not isinstance(selected_ai, str): + return + + registrar = CommandRegistrar() + integration = get_integration(selected_ai) + agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) + create_missing_skills = bool(init_opts.get("ai_skills")) and agent_config.get("extension") != "/SKILL.md" + + skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) + target_skill_names: List[str] = [] + if (skills_dir / skill_name).is_dir(): + target_skill_names.append(skill_name) + if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): + target_skill_names.append(legacy_skill_name) + if not target_skill_names and create_missing_skills: + missing_skill_dir = skills_dir / skill_name + if not missing_skill_dir.exists(): + target_skill_names.append(skill_name) + if not target_skill_names: + return + + raw_short_name = cmd_name + if raw_short_name.startswith("speckit."): + raw_short_name = raw_short_name[len("speckit."):] + short_name = raw_short_name.replace(".", "-") + skill_title = self._skill_title_from_command(cmd_name) + + frontmatter, body = registrar.parse_frontmatter(composed_content) + original_desc = frontmatter.get("description", "") + enhanced_desc = SKILL_DESCRIPTIONS.get( + short_name, + original_desc or f"Spec-kit workflow command: {short_name}", + ) + body = registrar.resolve_skill_placeholders( + selected_ai, dict(frontmatter), body, self.project_root + ) + + for target_skill_name in target_skill_names: + skill_subdir = skills_dir / target_skill_name + if skill_subdir.exists() and not skill_subdir.is_dir(): + continue + skill_subdir.mkdir(parents=True, exist_ok=True) + frontmatter_data = registrar.build_skill_frontmatter( + selected_ai, + target_skill_name, + enhanced_desc, + f"preset:{outermost_pack_id}", + ) + frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() + skill_content = ( + f"---\n" + f"{frontmatter_text}\n" + f"---\n\n" + f"# Speckit {skill_title} Skill\n\n" + f"{body}\n" + ) + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content(skill_content) + (skill_subdir / "SKILL.md").write_text(skill_content, encoding="utf-8") + def _get_skills_dir(self) -> Optional[Path]: """Return the active skills directory for preset skill overrides. @@ -624,7 +906,7 @@ def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]: try: manifest = ExtensionManifest(manifest_path) - except ValidationError: + except (ValidationError, TypeError, AttributeError): continue ext_root = ext_dir.resolve() @@ -761,6 +1043,13 @@ def _register_skills( content = source_file.read_text(encoding="utf-8") frontmatter, body = registrar.parse_frontmatter(content) + if frontmatter.get("strategy") == "wrap": + body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar) + frontmatter = dict(frontmatter) + for key in ("scripts", "agent_scripts"): + if key not in frontmatter and key in core_frontmatter: + frontmatter[key] = core_frontmatter[key] + original_desc = frontmatter.get("description", "") enhanced_desc = SKILL_DESCRIPTIONS.get( short_name, @@ -974,6 +1263,24 @@ def install_from_directory( # Update corresponding skills when --ai-skills was previously used registered_skills = self._register_skills(manifest, dest_dir) + # Detect wrap commands before registry.add() so a read failure doesn't + # leave a partially-committed registry entry. + wrap_commands = [] + try: + from .agents import CommandRegistrar as _CR + _registrar = _CR() + for cmd_tmpl in manifest.templates: + if cmd_tmpl.get("type") != "command": + continue + cmd_file = dest_dir / cmd_tmpl["file"] + if not cmd_file.exists(): + continue + cmd_fm, _ = _registrar.parse_frontmatter(cmd_file.read_text(encoding="utf-8")) + if cmd_fm.get("strategy") == "wrap": + wrap_commands.append(cmd_tmpl["name"]) + except ImportError: + pass + self.registry.add(manifest.id, { "version": manifest.version, "source": "local", @@ -982,8 +1289,12 @@ def install_from_directory( "priority": priority, "registered_commands": registered_commands, "registered_skills": registered_skills, + "wrap_commands": wrap_commands, }) + for cmd_name in wrap_commands: + self._replay_wraps_for_command(cmd_name) + return manifest def install_from_zip( @@ -1058,9 +1369,16 @@ def remove(self, pack_id: str) -> bool: # Restore original skills when preset is removed registered_skills = metadata.get("registered_skills", []) if metadata else [] registered_commands = metadata.get("registered_commands", {}) if metadata else {} + wrap_commands = metadata.get("wrap_commands", []) if metadata else [] pack_dir = self.presets_dir / pack_id + + # _unregister_skills must run before directory deletion (reads preset files) if registered_skills: self._unregister_skills(registered_skills, pack_dir) + # When _unregister_skills has already handled skill-agent files, strip + # those entries from registered_commands to avoid double-deletion. + # (When registered_skills is empty, skill-agent entries in + # registered_commands are the only deletion path for those files.) try: from .agents import CommandRegistrar except ImportError: @@ -1072,14 +1390,44 @@ def remove(self, pack_id: str) -> bool: if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" } - # Unregister non-skill command files from AI agents. - if registered_commands: - self._unregister_commands(registered_commands) - + # Delete the preset directory before mutating the registry so a + # filesystem failure cannot leave files on disk without a registry entry. if pack_dir.exists(): shutil.rmtree(pack_dir) + # Remove from registry before replaying so _replay_wraps_for_command sees + # the post-removal registry state. self.registry.remove(pack_id) + + # Separate wrap commands from non-wrap commands in registered_commands. + non_wrap_commands = { + agent_name: [c for c in cmd_names if c not in wrap_commands] + for agent_name, cmd_names in registered_commands.items() + } + non_wrap_commands = {k: v for k, v in non_wrap_commands.items() if v} + + # Unregister non-wrap command files from AI agents. + if non_wrap_commands: + self._unregister_commands(non_wrap_commands) + + # For each wrapped command, either re-compose remaining wraps or delete. + for cmd_name in wrap_commands: + remaining = [ + pid for pid, meta in self.registry.list().items() + if cmd_name in meta.get("wrap_commands", []) + ] + if remaining: + self._replay_wraps_for_command(cmd_name) + else: + # No wrap presets remain — delete the agent file entirely. + wrap_agent_commands = { + agent_name: [c for c in cmd_names if c == cmd_name] + for agent_name, cmd_names in registered_commands.items() + } + wrap_agent_commands = {k: v for k, v in wrap_agent_commands.items() if v} + if wrap_agent_commands: + self._unregister_commands(wrap_agent_commands) + return True def list_installed(self) -> List[Dict[str, Any]]: @@ -1735,6 +2083,7 @@ def resolve( self, template_name: str, template_type: str = "template", + skip_presets: bool = False, ) -> Optional[Path]: """Resolve a template name to its file path. @@ -1743,6 +2092,8 @@ def resolve( Args: template_name: Template name (e.g., "spec-template") template_type: Template type ("template", "command", or "script") + skip_presets: When True, skip tier 2 (installed presets). Use + resolve_core() as the preferred caller-facing API for this. Returns: Path to the resolved template file, or None if not found @@ -1771,7 +2122,7 @@ def resolve( return override # Priority 2: Installed presets (sorted by priority — lower number wins) - if self.presets_dir.exists(): + if not skip_presets and self.presets_dir.exists(): registry = PresetRegistry(self.presets_dir) for pack_id, _metadata in registry.list_by_priority(): pack_dir = self.presets_dir / pack_id @@ -1810,6 +2161,99 @@ def resolve( if core.exists(): return core + # Priority 5: Bundled core_pack (wheel install) or repo-root templates + # (source-checkout / editable install). This is the canonical home for + # speckit's built-in command/template files and must always be checked + # so that strategy:wrap presets can locate {CORE_TEMPLATE}. + from specify_cli import _locate_core_pack # local import to avoid cycles + _core_pack = _locate_core_pack() + if _core_pack is not None: + # Wheel install path + if template_type == "template": + candidate = _core_pack / "templates" / f"{template_name}.md" + elif template_type == "command": + candidate = _core_pack / "commands" / f"{template_name}.md" + elif template_type == "script": + candidate = _core_pack / "scripts" / f"{template_name}{ext}" + else: + candidate = _core_pack / f"{template_name}.md" + if candidate.exists(): + return candidate + else: + # Source-checkout / editable install: templates live at repo root + repo_root = Path(__file__).parent.parent.parent + if template_type == "template": + candidate = repo_root / "templates" / f"{template_name}.md" + elif template_type == "command": + candidate = repo_root / "templates" / "commands" / f"{template_name}.md" + elif template_type == "script": + candidate = repo_root / "scripts" / f"{template_name}{ext}" + else: + candidate = repo_root / f"{template_name}.md" + if candidate.exists(): + return candidate + + return None + + def resolve_core( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[Path]: + """Resolve while skipping installed presets (tier 2). + + Searches tiers 1, 3, 4, and 5 (bundled core_pack / repo-root fallback). + Use when resolving {CORE_TEMPLATE} to guarantee the result is actual + base content, never another preset's wrap output. + """ + return self.resolve(template_name, template_type, skip_presets=True) + + def resolve_extension_command_via_manifest(self, cmd_name: str) -> Optional[Path]: + """Resolve an extension command by consulting installed extension manifests. + + Walks installed extension directories in priority order, loads each + extension.yml via ExtensionManifest, and looks up the command by its + declared name to find the actual file path. This is necessary because + the manifest's ``provides.commands[].file`` field is authoritative and + may differ from the command name + (e.g. ``speckit.selftest.extension`` → ``commands/selftest.md``). + + Returns None if no manifest maps the given command name, so the caller + can fall back to the name-based lookup. + """ + if not self.extensions_dir.exists(): + return None + + from .extensions import ExtensionManifest, ValidationError + + for _priority, ext_id, _metadata in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + manifest_path = ext_dir / "extension.yml" + if not manifest_path.is_file(): + continue + try: + manifest = ExtensionManifest(manifest_path) + except (ValidationError, OSError, TypeError, AttributeError): + continue + for cmd_info in manifest.commands: + if cmd_info.get("name") != cmd_name: + continue + file_rel = cmd_info.get("file") + if not file_rel: + continue + # Mirror the containment check in ExtensionManager to guard against + # path traversal via a malformed manifest (e.g. file: ../../AGENTS.md). + cmd_path = Path(file_rel) + if cmd_path.is_absolute(): + continue + try: + ext_root = ext_dir.resolve() + candidate = (ext_root / cmd_path).resolve() + candidate.relative_to(ext_root) # raises ValueError if outside + except (OSError, ValueError): + continue + if candidate.is_file(): + return candidate return None def resolve_with_source( diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 04a91682e8..ff9386d626 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -261,7 +261,7 @@ def test_without_force_errors_on_existing_dir(self, tmp_path): ], catch_exceptions=False) assert result.exit_code == 1 - assert "already exists" in result.output + assert "already exists" in _normalize_cli_output(result.output) class TestGitExtensionAutoInstall: diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 5379178afe..e1acb8486b 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -217,6 +217,14 @@ def test_missing_required_field(self, temp_dir): with pytest.raises(ValidationError, match="Missing required field"): ExtensionManifest(manifest_path) + def test_non_mapping_yaml_raises_validation_error(self, temp_dir): + """Manifest whose YAML root is a scalar or list raises ValidationError, not TypeError.""" + manifest_path = temp_dir / "extension.yml" + for bad_content in ("42\n", "[]\n", "null\n"): + manifest_path.write_text(bad_content) + with pytest.raises(ValidationError, match="YAML mapping"): + ExtensionManifest(manifest_path) + def test_invalid_extension_id(self, temp_dir, valid_manifest_data): """Test manifest with invalid extension ID format.""" import yaml diff --git a/tests/test_presets.py b/tests/test_presets.py index 60322b99a1..d913c3b195 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -999,6 +999,94 @@ def test_resolve_skips_hidden_extension_dirs(self, project_dir): assert result is None +class TestResolveCore: + """Test PresetResolver.resolve_core() skips the installed-presets tier.""" + + def test_resolve_core_does_not_return_preset_files(self, project_dir): + """resolve_core must not return files from .specify/presets/.""" + preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" + preset_cmd_dir.mkdir(parents=True) + (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + # The preset file must never be returned — but the bundled core may be. + if result is not None: + assert "presets" not in result.parts + + def test_resolve_core_returns_core_template(self, project_dir): + """resolve_core falls through to core templates (tier 4).""" + core_cmd_dir = project_dir / ".specify" / "templates" / "commands" + core_cmd_dir.mkdir(parents=True, exist_ok=True) + (core_cmd_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") + + # Also place a preset file — resolve_core must still return the core + preset_cmd_dir = project_dir / ".specify" / "presets" / "my-preset" / "commands" + preset_cmd_dir.mkdir(parents=True) + (preset_cmd_dir / "specify.md").write_text("---\ndescription: preset wrap\n---\n\nwrap body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + assert result is not None + assert "presets" not in result.parts + assert result.parts[-3:] == ("templates", "commands", "specify.md") + + def test_resolve_core_returns_override(self, project_dir): + """resolve_core returns tier-1 override if present.""" + override_dir = project_dir / ".specify" / "templates" / "overrides" + override_dir.mkdir(parents=True) + (override_dir / "specify.md").write_text("---\ndescription: override\n---\n\noverride body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("specify", "command") + assert result is not None + assert result.parts[-2:] == ("overrides", "specify.md") + + def test_resolve_core_returns_extension_template(self, project_dir): + """resolve_core returns extension templates (tier 3).""" + ext_cmd_dir = project_dir / ".specify" / "extensions" / "myext" / "commands" + ext_cmd_dir.mkdir(parents=True) + (ext_cmd_dir / "myext-cmd.md").write_text("---\ndescription: ext\n---\n\next body\n") + + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("myext-cmd", "command") + assert result is not None + assert result.parts[-4:-1] == ("extensions", "myext", "commands") + + def test_resolve_core_returns_none_when_nothing_found(self, project_dir): + """resolve_core returns None when no file found in tiers 1/3/4.""" + resolver = PresetResolver(project_dir) + result = resolver.resolve_core("nonexistent", "command") + assert result is None + + def test_resolve_extension_command_via_manifest_skips_oserror_manifests(self, project_dir): + """resolve_extension_command_via_manifest skips extensions whose manifest raises OSError.""" + import unittest.mock as mock + + ext_dir = project_dir / ".specify" / "extensions" / "bad-ext" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "mycmd.md").write_text("---\ndescription: d\n---\n\nbody\n") + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: bad-ext\n name: Bad\n version: 1.0.0\n" + " description: d\n author: a\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n commands:\n" + " - name: speckit.bad-ext.mycmd\n" + " file: commands/mycmd.md\n" + " description: My command\n" + ) + + resolver = PresetResolver(project_dir) + # Simulate a permission error when opening the manifest file. + with mock.patch("builtins.open", side_effect=PermissionError("denied")): + result = resolver.resolve_extension_command_via_manifest("speckit.bad-ext.mycmd") + + assert result is None, "OSError during manifest load must be silently skipped" + + class TestExtensionPriorityResolution: """Test extension priority resolution with registered and unregistered extensions.""" @@ -1665,7 +1753,7 @@ def test_self_test_manifest_valid(self): assert manifest.id == "self-test" assert manifest.name == "Self-Test Preset" assert manifest.version == "1.0.0" - assert len(manifest.templates) == 7 # 6 templates + 1 command + assert len(manifest.templates) == 8 # 6 templates + 2 commands def test_self_test_provides_all_core_templates(self): """Verify the self-test preset provides an override for every core template.""" @@ -3041,3 +3129,1184 @@ def test_bundled_preset_missing_locally_cli_error(self, project_dir): output = strip_ansi(result.output).lower() assert "bundled" in output, result.output assert "reinstall" in output, result.output + + +class TestWrapStrategy: + """Tests for strategy: wrap preset command substitution.""" + + def test_substitute_core_template_replaces_placeholder(self, project_dir): + """Core template body replaces {CORE_TEMPLATE} in preset command body.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + # Set up a core command template + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\n---\n\n# Core Specify\n\nDo the thing.\n" + ) + + registrar = CommandRegistrar() + body = "## Pre-Logic\n\nBefore stuff.\n\n{CORE_TEMPLATE}\n\n## Post-Logic\n\nAfter stuff.\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + assert "{CORE_TEMPLATE}" not in result + assert "# Core Specify" in result + assert "## Pre-Logic" in result + assert "## Post-Logic" in result + assert core_fm.get("description") == "core" + + def test_substitute_core_template_no_op_when_placeholder_absent(self, project_dir): + """Returns body unchanged when {CORE_TEMPLATE} is not present.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCore body.\n") + + registrar = CommandRegistrar() + body = "## No placeholder here.\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + assert result == body + assert core_fm == {} + + def test_substitute_core_template_no_op_when_core_missing(self, project_dir): + """Returns body unchanged when core template file does not exist.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + registrar = CommandRegistrar() + body = "Pre.\n\n{CORE_TEMPLATE}\n\nPost.\n" + result, core_fm = _substitute_core_template(body, "nonexistent", project_dir, registrar) + assert result == body + assert "{CORE_TEMPLATE}" in result + assert core_fm == {} + + def test_register_commands_substitutes_core_template_for_wrap_strategy(self, project_dir): + """register_commands substitutes {CORE_TEMPLATE} when strategy: wrap.""" + from specify_cli.agents import CommandRegistrar + + # Set up core command template + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\n---\n\n# Core Specify\n\nCore body here.\n" + ) + + # Create a preset command dir with a wrap-strategy command + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: wrap test\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + commands = [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}] + registrar = CommandRegistrar() + + # Use a generic agent that writes markdown to commands/ + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + # Patch AGENT_CONFIGS to use a simple markdown agent pointing at our dir + import copy + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-agent", commands, "test-preset", + project_dir / "preset", project_dir + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "# Core Specify" in written + assert "## Pre" in written + assert "## Post" in written + + def test_end_to_end_wrap_via_self_test_preset(self, project_dir): + """Installing self-test preset with a wrap command substitutes {CORE_TEMPLATE}.""" + from specify_cli.presets import PresetManager + + # Install a core template that wrap-test will wrap around + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "wrap-test.md").write_text( + "---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n" + ) + + # Set up skills dir (simulating --ai claude) + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + skill_subdir = skills_dir / "speckit-wrap-test" + skill_subdir.mkdir() + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold content\n") + + # Write init-options so _register_skills finds the claude skills dir + import json + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + written = (skill_subdir / "SKILL.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "# Core Wrap-Test Body" in written + assert "preset:self-test wrap-pre" in written + assert "preset:self-test wrap-post" in written + + def test_substitute_core_template_returns_core_scripts(self, project_dir): + """core_frontmatter in the returned tuple includes scripts/agent_scripts.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: run.sh\nagent_scripts:\n sh: agent-run.sh\n---\n\n# Body\n" + ) + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + result, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + assert "# Body" in result + assert core_fm.get("scripts") == {"sh": "run.sh"} + assert core_fm.get("agent_scripts") == {"sh": "agent-run.sh"} + + def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, project_dir): + """_register_skills merges scripts/agent_scripts from core when preset lacks them.""" + from specify_cli.presets import PresetManager + import json + + # Core template with scripts + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "wrap-test.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh\n---\n\n" + "Run: {SCRIPT}\n" + ) + + # Skills dir for claude + skills_dir = project_dir / ".claude" / "skills" + skills_dir.mkdir(parents=True, exist_ok=True) + skill_subdir = skills_dir / "speckit-wrap-test" + skill_subdir.mkdir() + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-wrap-test\n---\n\nold\n") + + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager = PresetManager(project_dir) + manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + + written = (skill_subdir / "SKILL.md").read_text() + # {SCRIPT} should have been resolved (not left as a literal placeholder) + assert "{SCRIPT}" not in written + + def test_register_skills_preset_scripts_take_precedence_over_core(self, project_dir): + """preset-defined scripts/agent_scripts are not overwritten by core frontmatter.""" + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: core-run.sh\n---\n\nCore body.\n" + ) + + registrar = CommandRegistrar() + body = "{CORE_TEMPLATE}" + _, core_fm = _substitute_core_template(body, "specify", project_dir, registrar) + + # Simulate preset frontmatter that already defines scripts + preset_fm = {"description": "preset", "strategy": "wrap", "scripts": {"sh": "preset-run.sh"}} + for key in ("scripts", "agent_scripts"): + if key not in preset_fm and key in core_fm: + preset_fm[key] = core_fm[key] + + # Preset's scripts must not be overwritten by core + assert preset_fm["scripts"] == {"sh": "preset-run.sh"} + + def test_register_commands_inherits_scripts_from_core(self, project_dir): + """register_commands merges scripts/agent_scripts from core and normalizes paths.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + # Preset has strategy: wrap but no scripts of its own + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: wrap no scripts\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "Run:" in written + assert "scripts:" in written + assert "run.sh" in written + + def test_register_commands_toml_resolves_inherited_scripts(self, project_dir): + """TOML agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: toml wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + toml_dir = project_dir / ".gemini" / "commands" + toml_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-toml-agent"] = { + "dir": str(toml_dir.relative_to(project_dir)), + "format": "toml", + "args": "{{args}}", + "extension": ".toml", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-toml-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (toml_dir / "speckit.specify.toml").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "{SCRIPT}" not in written + assert "run.sh" in written + # args token must use TOML format, not the intermediate $ARGUMENTS + assert "$ARGUMENTS" not in written + assert "{{args}}" in written + + def test_register_commands_markdown_resolves_inherited_scripts(self, project_dir): + """Markdown agents resolve {SCRIPT} from inherited core scripts when preset omits them.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: markdown wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-md-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-md-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{CORE_TEMPLATE}" not in written + assert "{SCRIPT}" not in written + assert "run.sh" in written + assert "strategy" not in written + + def test_register_commands_markdown_converts_args_after_script_resolution(self, project_dir): + """Markdown agents re-run arg placeholder conversion after resolve_skill_placeholders. + + resolve_skill_placeholders injects $ARGUMENTS (via {ARGS} expansion). A second + _convert_argument_placeholder call must convert those to the agent's native format. + """ + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text( + "---\ndescription: core\nscripts:\n sh: .specify/scripts/run.sh {ARGS}\n---\n\n" + "Run: {SCRIPT}\n" + ) + + cmd_dir = project_dir / "preset" / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: forge wrap\nstrategy: wrap\n---\n\n" + "## Pre\n\n{CORE_TEMPLATE}\n\n## Post\n" + ) + + agent_dir = project_dir / ".forge" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-forge-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", + "args": "{{parameters}}", + "extension": ".md", + "strip_frontmatter_keys": [], + } + try: + registrar.register_commands( + "test-forge-agent", + [{"name": "speckit.specify", "file": "commands/speckit.specify.md"}], + "test-preset", + project_dir / "preset", + project_dir, + ) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "{SCRIPT}" not in written + assert "run.sh" in written + # $ARGUMENTS injected by resolve_skill_placeholders must be re-converted + assert "$ARGUMENTS" not in written + assert "{{parameters}}" in written + + def test_extension_command_resolves_via_extension_directory(self, project_dir): + """Extension commands (e.g. speckit.git.feature) resolve from the extension directory. + + Both _register_skills and register_commands pass the full cmd_name to + _substitute_core_template, which tries the full name first via PresetResolver + and finds speckit.git.feature.md in the extension commands directory. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + # Place the template where a real extension would install it + ext_cmd_dir = project_dir / ".specify" / "extensions" / "git" / "commands" + ext_cmd_dir.mkdir(parents=True, exist_ok=True) + (ext_cmd_dir / "speckit.git.feature.md").write_text( + "---\ndescription: git feature core\n---\n\n# Git Feature Core\n" + ) + # Ensure a hyphenated or dot-separated fallback does NOT exist + assert not (project_dir / ".specify" / "templates" / "commands" / "git.feature.md").exists() + assert not (project_dir / ".specify" / "templates" / "commands" / "git-feature.md").exists() + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + + # Both call sites now pass the full cmd_name + result, _ = _substitute_core_template(body, "speckit.git.feature", project_dir, registrar) + + assert "# Git Feature Core" in result + assert "{CORE_TEMPLATE}" not in result + + def test_extension_command_resolves_via_manifest_when_filename_differs(self, project_dir): + """Extension commands whose filename differs from the command name resolve via extension.yml. + + The selftest extension maps speckit.selftest.extension → commands/selftest.md. + Name-based lookup would look for commands/speckit.selftest.extension.md and fail; + manifest-based lookup must find the actual file declared in the manifest. + """ + from specify_cli.presets import _substitute_core_template + from specify_cli.agents import CommandRegistrar + + ext_dir = project_dir / ".specify" / "extensions" / "selftest" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + + # File is named selftest.md, NOT speckit.selftest.extension.md + (cmd_dir / "selftest.md").write_text( + "---\ndescription: selftest core\n---\n\n# Selftest Core\n" + ) + # Manifest maps the command name to the actual file + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " commands:\n" + " - name: speckit.selftest.extension\n" + " file: commands/selftest.md\n" + " description: Selftest command\n" + ) + + registrar = CommandRegistrar() + body = "## Wrapper\n\n{CORE_TEMPLATE}\n" + result, _ = _substitute_core_template(body, "speckit.selftest.extension", project_dir, registrar) + + assert "# Selftest Core" in result + assert "{CORE_TEMPLATE}" not in result + + +# ===== _replay_wraps_for_command Tests ===== + +def _make_wrap_preset_dir( + base: Path, + preset_id: str, + cmd_name: str, + pre: str, + post: str, + aliases: list[str] | None = None, + file_rel: str | None = None, +) -> Path: + """Create a minimal wrap-strategy preset directory for testing.""" + preset_dir = base / preset_id + cmd_dir = preset_dir / "commands" + cmd_dir.mkdir(parents=True) + file_rel = file_rel or f"commands/{cmd_name}.md" + template = { + "type": "command", + "name": cmd_name, + "file": file_rel, + "description": f"{preset_id} wrap", + } + if aliases is not None: + template["aliases"] = aliases + manifest = { + "schema_version": "1.0", + "preset": { + "id": preset_id, + "name": preset_id, + "version": "1.0.0", + "description": f"Preset {preset_id}", + "author": "test", + "repository": "https://example.com", + "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [template] + }, + "tags": [], + } + import yaml as _yaml + (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) + command_path = preset_dir / file_rel + command_path.parent.mkdir(parents=True, exist_ok=True) + command_path.write_text( + f"---\ndescription: {preset_id} wrap\nstrategy: wrap\n---\n\n" + f"[{pre}]\n\n{{CORE_TEMPLATE}}\n\n[{post}]\n" + ) + return preset_dir + + +class TestReplayWrapsForCommand: + """Tests for PresetManager._replay_wraps_for_command().""" + + def test_replay_no_op_when_no_wrap_presets(self, project_dir): + """replay does nothing when no presets declare wrap_commands for the command.""" + manager = PresetManager(project_dir) + # Should not raise + manager._replay_wraps_for_command("speckit.specify") + + def test_replay_no_op_when_core_missing(self, project_dir, temp_dir): + """replay exits gracefully when resolve_core returns None.""" + from specify_cli.agents import CommandRegistrar + import copy + + preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.nonexistent-cmd", "pre-a", "post-a") + installed = project_dir / ".specify" / "presets" / "preset-a" + import shutil as _shutil + _shutil.copytree(preset_dir, installed) + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.nonexistent-cmd"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + # No core file exists for this command — replay should return without writing + manager._replay_wraps_for_command("speckit.nonexistent-cmd") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + assert not (agent_dir / "speckit.nonexistent-cmd.md").exists() + + def test_replay_single_preset_writes_composed_output(self, project_dir, temp_dir): + """Single wrap preset: replay writes pre + core + post to agent dirs.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + # Core template + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") + + # Install preset-a + preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") + installed = project_dir / ".specify" / "presets" / "preset-a" + _shutil.copytree(preset_dir, installed) + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager._replay_wraps_for_command("speckit.specify") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "[pre-a]" in written + assert "core body" in written + assert "[post-a]" in written + assert "{CORE_TEMPLATE}" not in written + assert "strategy" not in written + + def test_replay_uses_manifest_command_file_mapping(self, project_dir, temp_dir): + """Replay reads wrapper files from preset.yml instead of assuming command-name paths.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + preset_dir = _make_wrap_preset_dir( + temp_dir, + "preset-a", + "speckit.specify", + "pre-a", + "post-a", + file_rel="commands/custom-wrapper.md", + ) + installed = project_dir / ".specify" / "presets" / "preset-a" + _shutil.copytree(preset_dir, installed) + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager._replay_wraps_for_command("speckit.specify") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + assert "[pre-a]" in written + assert "CORE" in written + assert "[post-a]" in written + + def test_replay_resolves_extension_core_via_manifest_mapping(self, project_dir, temp_dir): + """Replay finds extension core commands whose manifest file differs from command name.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + ext_dir = project_dir / ".specify" / "extensions" / "selftest" + cmd_dir = ext_dir / "commands" + cmd_dir.mkdir(parents=True, exist_ok=True) + (cmd_dir / "selftest.md").write_text( + "---\ndescription: selftest core\n---\n\nEXTENSION-CORE\n" + ) + (ext_dir / "extension.yml").write_text( + "schema_version: '1.0'\n" + "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" + " description: test\n author: test\n repository: https://example.com\n" + " license: MIT\n" + "requires:\n speckit_version: '>=0.2.0'\n" + "provides:\n" + " commands:\n" + " - name: speckit.selftest.extension\n" + " file: commands/selftest.md\n" + " description: Selftest command\n" + ) + + preset_dir = _make_wrap_preset_dir( + temp_dir, "preset-a", "speckit.selftest.extension", "pre-a", "post-a" + ) + installed = project_dir / ".specify" / "presets" / "preset-a" + _shutil.copytree(preset_dir, installed) + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.selftest.extension"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager._replay_wraps_for_command("speckit.selftest.extension") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.selftest.extension.md").read_text() + assert "[pre-a]" in written + assert "EXTENSION-CORE" in written + assert "[post-a]" in written + + def test_replay_priority_order_lower_number_outermost(self, project_dir, temp_dir): + """Two wrap presets: lower priority number = outermost wrapper.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + for pid in ("preset-outer", "preset-inner"): + src = _make_wrap_preset_dir(temp_dir, pid, "speckit.specify", f"pre-{pid}", f"post-{pid}") + _shutil.copytree(src, project_dir / ".specify" / "presets" / pid) + + manager = PresetManager(project_dir) + # preset-outer has priority 1 (highest precedence = outermost) + # preset-inner has priority 10 (lowest precedence = innermost) + for pid, pri in (("preset-outer", 1), ("preset-inner", 10)): + manager.registry.add(pid, { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": pri, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager._replay_wraps_for_command("speckit.specify") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + # Outermost (preset-outer, p=1) wraps everything; innermost (preset-inner, p=10) is next + outer_pre = written.index("[pre-preset-outer]") + inner_pre = written.index("[pre-preset-inner]") + core_pos = written.index("CORE") + inner_post = written.index("[post-preset-inner]") + outer_post = written.index("[post-preset-outer]") + assert outer_pre < inner_pre < core_pos < inner_post < outer_post + + def test_replay_install_order_independent(self, project_dir, temp_dir): + """Nesting order is determined by priority, not install order.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + for pid in ("preset-a", "preset-b"): + src = _make_wrap_preset_dir(temp_dir, pid, "speckit.specify", f"pre-{pid}", f"post-{pid}") + _shutil.copytree(src, project_dir / ".specify" / "presets" / pid) + + manager = PresetManager(project_dir) + # preset-a priority=5 (outermost), preset-b priority=10 (innermost) + # Install in reverse order to verify install order doesn't affect nesting + for pid, pri in (("preset-b", 10), ("preset-a", 5)): + manager.registry.add(pid, { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": pri, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True) + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager._replay_wraps_for_command("speckit.specify") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + a_pre = written.index("[pre-preset-a]") + b_pre = written.index("[pre-preset-b]") + core_pos = written.index("CORE") + b_post = written.index("[post-preset-b]") + a_post = written.index("[post-preset-a]") + # preset-a (p=5) is outermost regardless of install order + assert a_pre < b_pre < core_pos < b_post < a_post + + def test_replay_updates_skill_outputs(self, project_dir, temp_dir): + """Replay also rewrites SKILL.md-backed agent outputs.""" + import json + import shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") + _shutil.copytree(preset_dir, project_dir / ".specify" / "presets" / "preset-a") + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + skills_dir = project_dir / ".claude" / "skills" + skill_subdir = skills_dir / "speckit-specify" + skill_subdir.mkdir(parents=True) + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n") + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager._replay_wraps_for_command("speckit.specify") + + written = (skill_subdir / "SKILL.md").read_text() + assert "[pre-a]" in written + assert "CORE" in written + assert "[post-a]" in written + + def test_replay_applies_integration_post_processing_to_skill(self, project_dir, temp_dir): + """_replay_skill_override must call post_process_skill_content, matching _register_skills.""" + import json + import shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") + _shutil.copytree(preset_dir, project_dir / ".specify" / "presets" / "preset-a") + + manager = PresetManager(project_dir) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": ["speckit.specify"], + }) + + skills_dir = project_dir / ".claude" / "skills" + skill_subdir = skills_dir / "speckit-specify" + skill_subdir.mkdir(parents=True) + (skill_subdir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n") + (project_dir / ".specify" / "init-options.json").write_text( + json.dumps({"ai": "claude", "ai_skills": True}) + ) + + manager._replay_wraps_for_command("speckit.specify") + + # ClaudeIntegration.post_process_skill_content injects these flags. + # Their presence proves the integration hook ran during replay. + written = (skill_subdir / "SKILL.md").read_text() + assert "disable-model-invocation: false" in written, ( + "_replay_skill_override must call post_process_skill_content " + "(same as _register_skills)" + ) + + +class TestInstallRemoveWrapLifecycle: + """Tests for wrap_commands stored on install and replayed on remove.""" + + def _setup_agent(self, project_dir, registrar, agent_configs_dict): + """Register a test markdown agent and return its commands dir.""" + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + agent_configs_dict["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + return agent_dir + + def test_install_stores_wrap_commands_in_registry(self, project_dir, temp_dir): + """install_from_directory stores wrap_commands in the registry entry.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore\n") + + preset_src = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre", "post") + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + manager.install_from_directory(preset_src, "0.1.0", priority=10) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + meta = manager.registry.get("preset-a") + assert "wrap_commands" in meta + assert "speckit.specify" in meta["wrap_commands"] + + def test_install_replay_produces_correct_nested_output(self, project_dir, temp_dir): + """After installing two wrap presets, agent file contains correctly nested output.""" + from specify_cli.agents import CommandRegistrar + import copy, shutil as _shutil + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + # Install outermost first (priority=5), then innermost (priority=10) + outer_src = _make_wrap_preset_dir(temp_dir, "preset-outer", "speckit.specify", "OUTER-PRE", "OUTER-POST") + # Rename to avoid id conflict with fixture + inner_src = _make_wrap_preset_dir(temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST") + manager.install_from_directory(outer_src, "0.1.0", priority=5) + manager.install_from_directory(inner_src, "0.1.0", priority=10) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + outer_pre = written.index("OUTER-PRE") + inner_pre = written.index("INNER-PRE") + core_pos = written.index("CORE") + inner_post = written.index("INNER-POST") + outer_post = written.index("OUTER-POST") + assert outer_pre < inner_pre < core_pos < inner_post < outer_post + + def test_remove_replays_remaining_wraps(self, project_dir, temp_dir): + """Removing one wrap preset re-composes the remaining wraps correctly.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + outer_src = _make_wrap_preset_dir(temp_dir, "preset-outer", "speckit.specify", "OUTER-PRE", "OUTER-POST") + inner_src = _make_wrap_preset_dir(temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST") + manager.install_from_directory(outer_src, "0.1.0", priority=5) + manager.install_from_directory(inner_src, "0.1.0", priority=10) + manager.remove("preset-outer") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + written = (agent_dir / "speckit.specify.md").read_text() + # Only inner wrap remains — should be: INNER-PRE + CORE + INNER-POST, no OUTER + assert "INNER-PRE" in written + assert "CORE" in written + assert "INNER-POST" in written + assert "OUTER-PRE" not in written + assert "OUTER-POST" not in written + + def test_wrap_aliases_are_replayed_and_removed(self, project_dir, temp_dir): + """Replay preserves wrap aliases across install/remove lifecycle changes.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + outer_src = _make_wrap_preset_dir( + temp_dir, + "preset-outer", + "speckit.specify", + "OUTER-PRE", + "OUTER-POST", + aliases=["speckit.alias"], + ) + inner_src = _make_wrap_preset_dir( + temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST" + ) + manager.install_from_directory(outer_src, "0.1.0", priority=5) + manager.install_from_directory(inner_src, "0.1.0", priority=10) + + alias_file = agent_dir / "speckit.alias.md" + written = alias_file.read_text() + assert "OUTER-PRE" in written + assert "INNER-PRE" in written + assert "INNER-POST" in written + assert "OUTER-POST" in written + + manager.remove("preset-inner") + written = alias_file.read_text() + assert "OUTER-PRE" in written + assert "OUTER-POST" in written + assert "INNER-PRE" not in written + assert "INNER-POST" not in written + + manager.remove("preset-outer") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + assert not (agent_dir / "speckit.alias.md").exists() + + def test_remove_last_wrap_preset_deletes_agent_file(self, project_dir, temp_dir): + """Removing the only wrap preset deletes the agent command file.""" + from specify_cli.agents import CommandRegistrar + import copy + + core_dir = project_dir / ".specify" / "templates" / "commands" + core_dir.mkdir(parents=True, exist_ok=True) + (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + src = _make_wrap_preset_dir(temp_dir, "preset-only", "speckit.specify", "PRE", "POST") + manager.install_from_directory(src, "0.1.0", priority=10) + assert (agent_dir / "speckit.specify.md").exists() + manager.remove("preset-only") + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + assert not (agent_dir / "speckit.specify.md").exists() + + def test_remove_keeps_registry_entry_when_directory_delete_fails(self, project_dir, monkeypatch): + """A failed preset directory delete must not leave files untracked by the registry.""" + manager = PresetManager(project_dir) + pack_dir = manager.presets_dir / "preset-a" + pack_dir.mkdir(parents=True) + manager.registry.add("preset-a", { + "version": "1.0.0", "source": "local", "enabled": True, + "priority": 10, "manifest_hash": "x", + "registered_commands": {}, "registered_skills": [], + "wrap_commands": [], + }) + + def fail_rmtree(_path): + raise OSError("locked") + + monkeypatch.setattr(shutil, "rmtree", fail_rmtree) + + with pytest.raises(OSError, match="locked"): + manager.remove("preset-a") + + assert manager.registry.is_installed("preset-a") + assert pack_dir.exists() + + def test_non_wrap_commands_unaffected_by_wrap_lifecycle(self, project_dir, temp_dir): + """wrap_commands is empty for a preset with no strategy:wrap commands.""" + from specify_cli.agents import CommandRegistrar + import copy + import yaml as _yaml + + # Create a preset with a non-wrap command + preset_dir = temp_dir / "non-wrap-preset" + cmd_dir = preset_dir / "commands" + cmd_dir.mkdir(parents=True) + manifest = { + "schema_version": "1.0", + "preset": { + "id": "non-wrap-preset", "name": "Non-wrap", "version": "1.0.0", + "description": "no wrap", "author": "test", + "repository": "https://example.com", "license": "MIT", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": {"templates": [ + {"type": "command", "name": "speckit.specify", + "file": "commands/speckit.specify.md", "description": "override"}, + ]}, + "tags": [], + } + (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) + (cmd_dir / "speckit.specify.md").write_text( + "---\ndescription: plain override\n---\n\nplain body\n" + ) + + registrar = CommandRegistrar() + original = copy.deepcopy(registrar.AGENT_CONFIGS) + agent_dir = project_dir / ".claude" / "commands" + agent_dir.mkdir(parents=True, exist_ok=True) + registrar.AGENT_CONFIGS["test-agent"] = { + "dir": str(agent_dir.relative_to(project_dir)), + "format": "markdown", "args": "$ARGUMENTS", + "extension": ".md", "strip_frontmatter_keys": [], + } + try: + manager = PresetManager(project_dir) + manager.install_from_directory(preset_dir, "0.1.0", priority=10) + finally: + CommandRegistrar.AGENT_CONFIGS.clear() + CommandRegistrar.AGENT_CONFIGS.update(original) + + meta = manager.registry.get("non-wrap-preset") + assert meta.get("wrap_commands", []) == [] + written = (agent_dir / "speckit.specify.md").read_text() + assert "plain body" in written From dd9c0b050020e48e62623b0d5da5ef27d98d3a89 Mon Sep 17 00:00:00 2001 From: WangX <111967294+WangX0111@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:14:58 +0800 Subject: [PATCH 088/184] Add superpowers-bridge community extension (#2309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add superpowers-bridge community extension Adds the superpowers-bridge extension by WangX0111 to the community catalog and README table. This extension bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent-driven-development, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking. Extension details: - ID: superpowers-bridge - Repository: https://github.com/WangX0111/superspec - Version: 1.0.0 - Commands: 5, Hooks: 3 - License: MIT * Address Copilot review feedback - Update top-level updated_at to 2026-04-22 - Shorten description to under 200 characters --------- Co-authored-by: 乘浩 --- README.md | 1 + extensions/catalog.community.json | 37 +++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 38d319c9fa..c1c723c5b3 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ The following community-contributed extensions are available in [`catalog.commun | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | | Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | +| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) | | TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 53bea347ca..55848afb55 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-21T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -2122,6 +2122,39 @@ "created_at": "2026-03-30T00:00:00Z", "updated_at": "2026-04-16T14:08:23Z" }, + "superpowers-bridge": { + "name": "Superpowers Bridge", + "id": "superpowers-bridge", + "description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.", + "author": "WangX0111", + "version": "1.0.0", + "download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/WangX0111/superspec", + "homepage": "https://github.com/WangX0111/superspec", + "documentation": "https://github.com/WangX0111/superspec/blob/main/README.md", + "changelog": "https://github.com/WangX0111/superspec/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 5, + "hooks": 3 + }, + "tags": [ + "superpowers", + "brainstorming", + "tdd", + "code-review", + "subagent", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z" + }, "sync": { "name": "Spec Sync", "id": "sync", @@ -2405,4 +2438,4 @@ "updated_at": "2026-04-13T00:00:00Z" } } -} +} \ No newline at end of file From 4dcf2921d1f725f9bde43bcc9830bbfbb980990a Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Wed, 22 Apr 2026 15:33:08 +0200 Subject: [PATCH 089/184] feat(catalog): add red-team extension to community catalog (#2306) * feat(catalog): add red-team extension Adds the `red-team` community extension to the catalog: - Adversarial review of functional specs before /speckit.plan locks in architecture. - Complements /speckit.clarify (correctness) and /speckit.analyze (consistency) with parallel adversarial lens agents. - One command: speckit.red-team.run - MIT licensed; requires spec-kit >= 0.7.0. Origin: this extension was originally proposed as a core command (github/spec-kit#2303). Per maintainer guidance (mnriem's comment on that PR), it's been restructured as a community extension hosted at https://github.com/ashbrener/spec-kit-red-team. Dogfood-validated on a 500-line functional spec: 5 lens agents dispatched in parallel returned 25 findings in ~1.5 min wall-clock, 19 of which met the meaningful-finding bar (severity >= HIGH AND novel adversarial angle that clarify/analyze structurally cannot catch). Full detail in the extension's CHANGELOG. * catalog: shorten red-team description to fit <200 char schema limit Resolves Copilot review comment on #2306. Previous description (259 chars) exceeded the extensions/EXTENSION-PUBLISHING-GUIDE.md Appendix schema ceiling. Shortened to 188 chars, keeping the distinctive value proposition (adversarial, complements clarify/analyze) and moving the per-phase mechanics to the extension's own README. * catalog: bump red-team to v1.0.1 (lower required spec-kit version) Follow-up to v1.0.0 catalog entry: - version: 1.0.0 -> 1.0.1 - download_url: points at v1.0.1 release asset - requires.speckit_version: >=0.7.0 -> >=0.1.0 The v1.0.0 requirement was too strict and blocked installation on common 0.6.x field versions (confirmed via local install attempt). The extension uses no 0.7.x-specific APIs; matches community norm (reconcile, refine, others use >=0.1.0). * catalog: bump red-team to v1.0.2 (adds mandatory before_plan gate) v1.0.2 ships a /speckit.red-team.gate command wired as a mandatory before_plan hook so /speckit.plan auto-invokes it on every run against qualifying specs. Non-qualifying specs return PROCEED silently; qualifying specs without findings on record return HALT with explicit remediation (run /speckit.red-team.run, or opt out via --skip-red-team-gate: which is recorded as an Accepted Risk [red-team-skipped] in the plan). Catalog metadata delta: - version: 1.0.1 -> 1.0.2 - download_url: v1.0.2/red-team-v1.0.2.zip - provides.commands: 1 -> 2 (adds speckit.red-team.gate) - provides.hooks: 0 -> 1 (adds before_plan hook) No breaking changes. Projects that do not want the gate simply do not install the extension. --------- Co-authored-by: Ash Brener --- extensions/catalog.community.json | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 55848afb55..ff4718c96a 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1523,6 +1523,38 @@ "created_at": "2026-03-14T00:00:00Z", "updated_at": "2026-03-14T00:00:00Z" }, + "red-team": { + "name": "Red Team", + "id": "red-team", + "description": "Adversarial review of functional specs before /speckit.plan. Parallel adversarial lens agents catch hostile actors, silent failures, and regulatory blind spots that clarify/analyze cannot.", + "author": "Ash Brener", + "version": "1.0.2", + "download_url": "https://github.com/ashbrener/spec-kit-red-team/releases/download/v1.0.2/red-team-v1.0.2.zip", + "repository": "https://github.com/ashbrener/spec-kit-red-team", + "homepage": "https://github.com/ashbrener/spec-kit-red-team", + "documentation": "https://github.com/ashbrener/spec-kit-red-team/blob/main/README.md", + "changelog": "https://github.com/ashbrener/spec-kit-red-team/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 2, + "hooks": 1 + }, + "tags": [ + "adversarial-review", + "quality-gate", + "spec-hardening", + "pre-plan", + "audit" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z" + }, "refine": { "name": "Spec Refine", "id": "refine", From deb80956f381121837dd609ff5f08a0806dad089 Mon Sep 17 00:00:00 2001 From: Ash Brener Date: Wed, 22 Apr 2026 16:03:06 +0200 Subject: [PATCH 090/184] docs(readme): list red-team in community-extensions table (#2311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2306 (merged). Per maintainer request (https://github.com/github/spec-kit/pull/2306#issuecomment-4296655643), adds the red-team entry to the alphabetically-ordered community-extensions table in README.md so the extension is discoverable alongside the other community entries — not only via catalog.community.json. Slotted alphabetically between "Reconcile Extension" and "Repository Index". Category: docs. Effect: Read+Write (produces a structured findings-report file at specs//red-team-findings-*.md; does not modify specs — every resolution is maintainer-authorised). Co-authored-by: Ash Brener --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c1c723c5b3..618e485045 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | | Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/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) | +| 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) | | Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) | | Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) | | Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) | From d402a392c3a4d0f9d57d9e810a36a1eff8f61777 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:17:48 -0500 Subject: [PATCH 091/184] Move community walkthroughs from README to docs/community (#2312) * Move community walkthroughs from README to docs/community Extract the community walkthroughs section from README.md into its own docs/community/walkthroughs.md file and replace it with a short summary linking to the GitHub Pages URL. * Address review: fix double-See phrasing, add walkthroughs to docs nav --- README.md | 19 +------------------ docs/community/walkthroughs.md | 20 ++++++++++++++++++++ docs/toc.yml | 2 ++ 3 files changed, 23 insertions(+), 18 deletions(-) create mode 100644 docs/community/walkthroughs.md diff --git a/README.md b/README.md index 618e485045..a1c0af220e 100644 --- a/README.md +++ b/README.md @@ -291,24 +291,7 @@ To build and publish your own preset, see the [Presets Publishing Guide](presets ## 🚶 Community Walkthroughs -> [!NOTE] -> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion. - -See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs: - -- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents. - -- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included. - -- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution. - -- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution. - -- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal. - -- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling. - -- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit. +See Spec-Driven Development in action across different scenarios with community-contributed walkthroughs; find the full list on the [Community Walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html) page. ## 🛠️ Community Friends diff --git a/docs/community/walkthroughs.md b/docs/community/walkthroughs.md new file mode 100644 index 0000000000..b32c025803 --- /dev/null +++ b/docs/community/walkthroughs.md @@ -0,0 +1,20 @@ +# Community Walkthroughs + +> [!NOTE] +> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion. + +See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs: + +- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents. + +- **[Greenfield Spring Boot + React platform](https://github.com/mnriem/spec-kit-spring-react-demo)** — Builds an LLM performance analytics platform (REST API, graphs, iteration tracking) from scratch using Spring Boot, embedded React, PostgreSQL, and Docker Compose, with a clarify step and a cross-artifact consistency analysis pass included. + +- **[Brownfield ASP.NET CMS extension](https://github.com/mnriem/spec-kit-aspnet-brownfield-demo)** — Extends an existing open-source .NET CMS (CarrotCakeCMS-Core, ~307,000 lines of C#, Razor, SQL, JavaScript, and config files) with two new features — cross-platform Docker Compose infrastructure and a token-authenticated headless REST API — demonstrating how spec-kit fits into existing codebases without prior specs or a constitution. + +- **[Brownfield Java runtime extension](https://github.com/mnriem/spec-kit-java-brownfield-demo)** — Extends an existing open-source Jakarta EE runtime (Piranha, ~420,000 lines of Java, XML, JSP, HTML, and config files across 180 Maven modules) with a password-protected Server Admin Console, demonstrating spec-kit on a large multi-module Java project with no prior specs or constitution. + +- **[Brownfield Go / React dashboard demo](https://github.com/mnriem/spec-kit-go-brownfield-demo)** — Demonstrates spec-kit driven entirely from the **terminal using GitHub Copilot CLI**. Extends NASA's open-source Hermes ground support system (Go) with a lightweight React-based web telemetry dashboard, showing that the full constitution → specify → plan → tasks → implement workflow works from the terminal. + +- **[Greenfield Spring Boot MVC with a custom preset](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)** — Builds a Spring Boot MVC application from scratch using a custom pirate-speak preset, demonstrating how presets can reshape the entire spec-kit experience: specifications become "Voyage Manifests," plans become "Battle Plans," and tasks become "Crew Assignments" — all generated in full pirate vernacular without changing any tooling. + +- **[Greenfield Spring Boot + React with a custom extension](https://github.com/mnriem/spec-kit-aide-extension-demo)** — Walks through the **AIDE extension**, a community extension that adds an alternative spec-driven workflow to spec-kit with high-level specs (vision) and low-level specs (work items) organized in a 7-step iterative lifecycle: vision → roadmap → progress tracking → work queue → work items → execution → feedback loops. Uses a family trading platform (Spring Boot 4, React 19, PostgreSQL, Docker Compose) as the scenario to illustrate how the extension mechanism lets you plug in a different style of spec-driven development without changing any core tooling — truly utilizing the "Kit" in Spec Kit. diff --git a/docs/toc.yml b/docs/toc.yml index add814d757..ba65853d1f 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -37,5 +37,7 @@ # Community - name: Community items: + - name: Walkthroughs + href: community/walkthroughs.md - name: Friends href: community/friends.md From c52ea23ba2acbd2ac35f846844695232b7151698 Mon Sep 17 00:00:00 2001 From: TortoiseWolfe <101219421+TortoiseWolfe@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:20:13 -0400 Subject: [PATCH 092/184] catalog: add wireframe extension (v0.1.1) (#2262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * catalog: add wireframe extension Adds https://github.com/TortoiseWolfe/spec-kit-extension-wireframe (v0.1.0) to the community catalog. Provides a visual feedback loop for spec-driven development: SVG wireframe generation, review, and sign-off. Approved wireframes become spec constraints honored by /plan, /tasks, and /implement. Supersedes #1410 — the old PR predated the extension system introduced in #2130 and proposed commands in templates/commands/, which is no longer the right home for third-party commands. * catalog: address review feedback (position + author) Two changes per Copilot review: - Move `wireframe` entry alphabetically between `whatif` and `worktree` (was appended after `worktrees`). - Simplify `author` from "TortoiseWolfe (turtlewolfe.com)" to just "TortoiseWolfe" so the exact-match author filter in `ExtensionCatalog.search` finds the entry. Portfolio URL remains accessible via `homepage`/`repository`. Thanks @Copilot, @mnriem for the review. * docs(readme): add Wireframe Visual Feedback Loop row Addresses @mnriem's follow-up: the README extension table also needs an entry, not just the catalog JSON. Slots in alphabetically between "What-if Analysis" and "Worktree Isolation" with category `visibility` and Read+Write effect (since sign-off writes the approved wireframe paths into spec.md). * catalog: use speckit-prefixed command names in wireframe description Address remaining Copilot review comment on PR #2262. The actual commands are /speckit.plan, /speckit.tasks, /speckit.implement; the unprefixed names would mislead catalog users. Co-Authored-By: Claude Opus 4.7 (1M context) * catalog: bump wireframe extension to v0.1.1 v0.1.1 of spec-kit-extension-wireframe ships the /speckit.-prefixed command references in extension.yml and README.md. This updates the catalog entry to point at the new release tag so `specify extension add wireframe` installs the corrected version. * catalog: set wireframe created_at to current timestamp Per EXTENSION-PUBLISHING-GUIDE.md: newly added entries should use the current timestamp for both created_at and updated_at. The 04-17 value reflected when I drafted the entry locally, not when the catalog submission landed. --------- Co-authored-by: TortoiseWolfe Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 1 + extensions/catalog.community.json | 35 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/README.md b/README.md index a1c0af220e..15e528b5e6 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,7 @@ The following community-contributed extensions are available in [`catalog.commun | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | | Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) | | What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) | +| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) | | Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) | | Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ff4718c96a..9ddc29b558 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2405,6 +2405,41 @@ "created_at": "2026-04-13T00:00:00Z", "updated_at": "2026-04-13T00:00:00Z" }, + "wireframe": { + "name": "Wireframe Visual Feedback Loop", + "id": "wireframe", + "description": "SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement.", + "author": "TortoiseWolfe", + "version": "0.1.1", + "download_url": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/archive/refs/tags/v0.1.1.zip", + "repository": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe", + "homepage": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe", + "documentation": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/README.md", + "changelog": "https://github.com/TortoiseWolfe/spec-kit-extension-wireframe/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 6, + "hooks": 3 + }, + "tags": [ + "wireframe", + "visual", + "design", + "ui", + "mockup", + "svg", + "feedback-loop", + "sign-off" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T00:00:00Z" + }, "worktree": { "name": "Worktree Isolation", "id": "worktree", From efb04e26ebd21d2640e3d4dd9f7e5e1e2e385080 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:05:14 -0500 Subject: [PATCH 093/184] docs: move community presets from README to docs/community (#2314) Move the community presets table from the main README to a dedicated docs/community/presets.md page, matching the pattern used for walkthroughs and friends. - Add docs/community/presets.md with the full presets table - Add Claude AskUserQuestion preset (was in catalog but missing from table) - Add Presets entry to docs/toc.yml under Community - Replace inline README table with a short link to the docs page --- README.md | 18 +----------------- docs/community/presets.md | 20 ++++++++++++++++++++ docs/toc.yml | 2 ++ 3 files changed, 23 insertions(+), 17 deletions(-) create mode 100644 docs/community/presets.md diff --git a/README.md b/README.md index 15e528b5e6..468608a6e4 100644 --- a/README.md +++ b/README.md @@ -272,23 +272,7 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX ## 🎨 Community Presets -> [!NOTE] -> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. - -The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json): - -| Preset | Purpose | Provides | Requires | URL | -|--------|---------|----------|----------|-----| -| 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) | -| 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) | -| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | -| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | -| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | -| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | -| 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) | - -To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md). +Community-contributed presets that customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page. ## 🚶 Community Walkthroughs diff --git a/docs/community/presets.md b/docs/community/presets.md new file mode 100644 index 0000000000..c214e97c03 --- /dev/null +++ b/docs/community/presets.md @@ -0,0 +1,20 @@ +# Community Presets + +> [!NOTE] +> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. + +The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/presets/catalog.community.json): + +| Preset | Purpose | Provides | Requires | URL | +|--------|---------|----------|----------|-----| +| 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) | +| 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) | +| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | +| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | +| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| 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) | + +To build and publish your own preset, see the [Presets Publishing Guide](https://github.com/github/spec-kit/blob/main/presets/PUBLISHING.md). diff --git a/docs/toc.yml b/docs/toc.yml index ba65853d1f..636a8f03a1 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -37,6 +37,8 @@ # Community - name: Community items: + - name: Presets + href: community/presets.md - name: Walkthroughs href: community/walkthroughs.md - name: Friends From 58f7a43ec35b2af9f1c7d34e07025f2170c50d68 Mon Sep 17 00:00:00 2001 From: Kevin Brown <51965072+KevinBrown5280@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:27:56 -0400 Subject: [PATCH 094/184] Update version-guard to v1.1.0 (#2318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version: 1.0.0 → 1.1.0 - Commands: 1 → 3 (check, load, validate) - Hooks: 2 → 4 (before_plan, before_tasks, before_implement, after_implement) - Added: persistent constraints artifact, two-channel model, CVE detection, decision record fallback for greenfield projects, skip artifact persistence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 9ddc29b558..aa59d27bfa 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-22T00:00:00Z", + "updated_at": "2026-04-22T17:54:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -2351,8 +2351,8 @@ "id": "version-guard", "description": "Verify tech stack versions against live registries before planning and implementation", "author": "KevinBrown5280", - "version": "1.0.0", - "download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.0.0.zip", + "version": "1.1.0", + "download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.1.0.zip", "repository": "https://github.com/KevinBrown5280/spec-kit-version-guard", "homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard", "documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md", @@ -2362,8 +2362,8 @@ "speckit_version": ">=0.2.0" }, "provides": { - "commands": 1, - "hooks": 2 + "commands": 3, + "hooks": 4 }, "tags": [ "versioning", @@ -2375,7 +2375,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-20T00:00:00Z", - "updated_at": "2026-04-20T00:00:00Z" + "updated_at": "2026-04-22T17:54:00Z" }, "whatif": { "name": "What-if Analysis", From c5c20134df2364ac3f967bbfe5a41195b19d611e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A4=80=ED=98=B8?= <101695482+chordpli@users.noreply.github.com> Date: Thu, 23 Apr 2026 03:33:03 +0900 Subject: [PATCH 095/184] feat(cli): add specify self check and self upgrade stub (#2316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add specify self check and self upgrade stub (#2282) Introduce a new `specify self` Typer sub-app with two subcommands. `specify self check` performs a read-only lookup against the GitHub Releases API, compares the installed version to the latest tag with PEP 440 semantics, and prints one of four verdicts (newer-available, up-to-date, indeterminate, graceful-failure). When a newer stable release is available, the output includes a copy-pasteable `uv tool install --force --from git+...@` reinstall command. `GH_TOKEN` / `GITHUB_TOKEN` is attached as a bearer credential when set so users behind shared IPs escape the anonymous 60/hour rate limit. `specify self upgrade` is a documented non-destructive stub in this release: three-line guidance output, exit 0, no outbound call, no install-method detection. The real destructive implementation is planned as follow-up work. Failure categorization is a fixed three-entry enum (offline or timeout, rate limited, HTTP ). Anything outside those three categories propagates as a non-zero exit so bugs surface instead of being silently swallowed. No machine-readable output, no retries, no caching in this release — see issue #2282 discussion. Tests mock `urllib.request.urlopen`; the suite performs zero real network calls. Full regression suite: 1586 passed. * fix(cli): disable Rich highlight for deterministic output Rich's default `highlight=True` applies ANSI color to detected patterns (integers, version strings, paths) whenever stdout is deemed a TTY. This caused intermittent failures in existing pytest assertions in tests/test_cli_version.py and tests/test_extensions.py::TestExtensionRemoveCLI that compare plain-text output without passing through `strip_ansi()`. Setting `Console(highlight=False)` globally makes all CLI output deterministic and fixes the flake without modifying the affected tests. The numeric cyan highlighting was not a documented part of the CLI visual contract. * fix: address copilot review feedback * fix: tighten self-check token handling * fix: align self-check helpers and script metadata * fix: harden self-check version handling * fix: guard self-check failure rendering --- src/specify_cli/__init__.py | 184 ++++++++++++++++-- tests/test_upgrade.py | 371 ++++++++++++++++++++++++++++++++++++ 2 files changed, 538 insertions(+), 17 deletions(-) create mode 100644 tests/test_upgrade.py diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 97cb993a96..839562f111 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -7,6 +7,8 @@ # "platformdirs", # "readchar", # "json5", +# "pyyaml", +# "packaging", # ] # /// """ @@ -34,8 +36,12 @@ import json5 import stat import shlex +import urllib.error +import urllib.request import yaml from pathlib import Path + +from packaging.version import InvalidVersion, Version from typing import Any, Optional import typer @@ -51,6 +57,8 @@ # For cross-platform keyboard input import readchar +GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest" + def _build_agent_config() -> dict[str, dict[str, Any]]: """Derive AGENT_CONFIG from INTEGRATION_REGISTRY.""" from .integrations import INTEGRATION_REGISTRY @@ -318,7 +326,7 @@ def run_selection_loop(): return selected_key -console = Console() +console = Console(highlight=False) class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" @@ -1599,25 +1607,10 @@ def check(): def version(): """Display version and system information.""" import platform - import importlib.metadata show_banner() - # Get CLI version from package metadata - cli_version = "unknown" - try: - cli_version = importlib.metadata.version("specify-cli") - except Exception: - # Fallback: try reading from pyproject.toml if running from source - try: - import tomllib - pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" - if pyproject_path.exists(): - with open(pyproject_path, "rb") as f: - data = tomllib.load(f) - cli_version = data.get("project", {}).get("version", "unknown") - except Exception: - pass + cli_version = get_speckit_version() info_table = Table(show_header=False, box=None, padding=(0, 2)) info_table.add_column("Key", style="cyan", justify="right") @@ -1640,6 +1633,163 @@ def version(): console.print(panel) console.print() +def _get_installed_version() -> str: + """Return the installed specify-cli distribution version or 'unknown'. + + Uses importlib.metadata so the value reflects what was actually installed + by pip/uv/pipx — not a value read from pyproject.toml. This is + intentional for `specify self check`, which should reason about the + installed distribution rather than a source-tree fallback. Callers must + treat the sentinel string 'unknown' as an indeterminate value (see FR-020). + """ + + import importlib.metadata + + metadata_errors = [importlib.metadata.PackageNotFoundError] + invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) + if invalid_metadata_error is not None: + metadata_errors.append(invalid_metadata_error) + + try: + return importlib.metadata.version("specify-cli") + except tuple(metadata_errors): + return "unknown" + +def _normalize_tag(tag: str) -> str: + """Strip exactly one leading 'v' from a release tag. + + Returns the rest of the string unchanged. This handles the common + 'vX.Y.Z' tag convention in this repo; it MUST NOT strip more + aggressively (e.g., two leading 'v's keeps one). + """ + return tag[1:] if tag.startswith("v") else tag + +def _is_newer(latest: str, current: str) -> bool: + """Return True iff `latest` is strictly greater than `current` under PEP 440. + + Returns False whenever either side is 'unknown' or fails to parse; this + keeps the comparison indeterminate (rather than crashing or falsely + recommending a downgrade) on edge inputs. + """ + if latest == "unknown" or current == "unknown": + return False + try: + return Version(latest) > Version(current) + except InvalidVersion: + return False + + +def _fetch_latest_release_tag() -> tuple[str | None, str | None]: + """Return (tag, failure_category). Exactly one outbound call, 5 s timeout. + + On success: (tag_name, None). + On a documented network/HTTP failure (added in T029/T030): (None, category). + On anything else — including a malformed response body — the exception + propagates; there is no catch-all (research D-006). + """ + req = urllib.request.Request( + GITHUB_API_LATEST, + headers={"Accept": "application/vnd.github+json"}, + ) + token = None + for env_var in ("GH_TOKEN", "GITHUB_TOKEN"): + candidate = os.environ.get(env_var) + if candidate is not None: + candidate = candidate.strip() + if candidate: + token = candidate + break + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req, timeout=5) as resp: + payload = json.loads(resp.read().decode("utf-8")) + tag = payload.get("tag_name") + if not isinstance(tag, str) or not tag: + raise ValueError("GitHub API response missing valid tag_name") + return tag, None + except urllib.error.HTTPError as e: + # Order matters: HTTPError is a subclass of URLError. + if e.code == 403: + return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)" + return None, f"HTTP {e.code}" + except (urllib.error.URLError, OSError): + return None, "offline or timeout" + + +# ===== Self Commands ===== +self_app = typer.Typer( + name="self", + help="Manage the specify CLI itself (read-only check and reserved upgrade command).", + add_completion=False, +) +app.add_typer(self_app, name="self") + +@self_app.command("check") +def self_check() -> None: + """Check whether a newer specify-cli release is available. Read-only. + + This command only checks for updates; it does not modify your installation. + The reserved (and currently non-destructive) `specify self upgrade` command + is the name that a future release will use for actual self-upgrade — its + behavior is not implemented in this release and is intentionally out of + scope here. See `specify self upgrade --help` for its current status. + """ + + installed = _get_installed_version() + tag, failure_reason = _fetch_latest_release_tag() + + if tag is None: + # Graceful-failure path (FR-008). `failure_reason` is one of the + # enumerated strings produced by _fetch_latest_release_tag() — it + # never contains a URL, headers, response body, or traceback. + assert failure_reason is not None + console.print(f"Installed: {installed}") + console.print(f"[yellow]Could not check latest release:[/yellow] {failure_reason}") + return + + latest_normalized = _normalize_tag(tag) + + if installed == "unknown": + # FR-020: surface the latest release and the recovery action even + # when the local distribution metadata is unavailable. + console.print("Current version could not be determined.") + console.print(f"Latest release: {latest_normalized}") + console.print("\nTo reinstall:") + console.print(" uv tool install specify-cli --force \\") + console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") + return + + if _is_newer(latest_normalized, installed): + console.print(f"[green]Update available:[/green] {installed} → {latest_normalized}") + console.print("\nTo upgrade:") + console.print(" uv tool install specify-cli --force \\") + console.print(f" --from git+https://github.com/github/spec-kit.git@{tag}") + return + + # Installed is parseable AND is >= latest → "up to date" (FR-006). + # Also reached when the tag is unparseable (InvalidVersion) → _is_newer + # returns False, and the up-to-date branch is the safer default per + # FR-004 / test T016. + console.print(f"[green]Up to date:[/green] {installed}") + + +@self_app.command("upgrade") +def self_upgrade() -> None: + """Reserved command surface for self-upgrade; not implemented in this release. + + This command is a documented non-destructive stub in this release: it + performs no outbound network request, no install-method detection, and + invokes no installer. It prints a three-line guidance message and exits 0. + Actual self-upgrade is planned as follow-up work. + + Use `specify self check` today to see whether a newer release is available + and to get a copy-pasteable reinstall command. + """ + console.print("specify self upgrade is not implemented yet.") + console.print("Run 'specify self check' to see whether a newer release is available.") + console.print("Actual self-upgrade is planned as follow-up work.") + # ===== Extension Commands ===== diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py new file mode 100644 index 0000000000..96aa627874 --- /dev/null +++ b/tests/test_upgrade.py @@ -0,0 +1,371 @@ +"""Tests for the `specify self` sub-app (`self check` and `self upgrade`). + +Network isolation contract (SC-004 / FR-014): every test that exercises +`specify self check` or `_fetch_latest_release_tag()` MUST mock +`urllib.request.urlopen` so no real outbound call ever reaches +api.github.com. The `self upgrade` stub tests do not need that patch because +the stub is contractually network-free. Run this module under `pytest-socket` +(if installed) with `--disable-socket` as an extra safety net. +""" + +import json +import urllib.error +import importlib.metadata +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from specify_cli import ( + _get_installed_version, + _fetch_latest_release_tag, + _is_newer, + _normalize_tag, + app, +) + +from tests.conftest import strip_ansi + +runner = CliRunner() + +SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE" +SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" + + +def _mock_urlopen_response(payload: dict) -> MagicMock: + body = json.dumps(payload).encode("utf-8") + resp = MagicMock() + resp.read.return_value = body + cm = MagicMock() + cm.__enter__.return_value = resp + cm.__exit__.return_value = False + return cm + + +def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError: + return urllib.error.HTTPError( + url="https://api.github.com/repos/github/spec-kit/releases/latest", + code=code, + msg=message, + hdrs={}, # type: ignore[arg-type] + fp=None, + ) + + +class TestSelfUpgradeStub: + """Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016).""" + + def test_prints_exactly_three_lines_and_exits_zero(self): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + lines = strip_ansi(result.output).strip().splitlines() + assert lines == [ + "specify self upgrade is not implemented yet.", + "Run 'specify self check' to see whether a newer release is available.", + "Actual self-upgrade is planned as follow-up work.", + ] + + def test_stub_makes_no_network_call(self): + # If the stub ever starts calling urllib, this patch's side_effect + # would fire and the assertion below would fail. + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=AssertionError("stub must not hit the network"), + ): + result = runner.invoke(app, ["self", "upgrade"]) + assert result.exit_code == 0 + + +class TestIsNewer: + def test_latest_strictly_greater_returns_true(self): + assert _is_newer("0.8.0", "0.7.4") is True + + def test_equal_versions_returns_false(self): + assert _is_newer("0.7.4", "0.7.4") is False + + def test_current_greater_than_latest_returns_false(self): + assert _is_newer("0.7.0", "0.7.4") is False + + def test_dev_build_ahead_of_release_returns_false(self): + assert _is_newer("0.7.4", "0.7.5.dev0") is False + + def test_invalid_version_returns_false(self): + assert _is_newer("not-a-version", "0.7.4") is False + + def test_local_version_containing_unknown_is_not_treated_as_sentinel(self): + assert _is_newer("1.2.4", "1.2.3+unknown") is True + + +class TestInstalledVersion: + def test_invalid_metadata_error_returns_unknown(self): + invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) + if invalid_metadata_error is None: + pytest.skip("InvalidMetadataError is not available on this Python version") + with patch( + "importlib.metadata.version", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert _get_installed_version() == "unknown" + + +class TestNormalizeTag: + def test_strips_single_leading_v(self): + assert _normalize_tag("v0.7.4") == "0.7.4" + + def test_idempotent_when_no_leading_v(self): + assert _normalize_tag("0.7.4") == "0.7.4" + + def test_strips_exactly_one_v(self): + assert _normalize_tag("vv0.7.4") == "v0.7.4" + + def test_empty_string_passthrough(self): + assert _normalize_tag("") == "" + + +class TestUserStory1: + def test_newer_available_prints_update_and_install_command(self): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" in output + assert "0.7.4" in output + assert "0.9.0" in output + assert "git+https://github.com/github/spec-kit.git@v0.9.0" in output + + def test_up_to_date_prints_current_only(self): + with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch( + "specify_cli.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Up to date: 0.9.0" in output + assert "Update available" not in output + assert "git+https://" not in output + + def test_dev_build_ahead_of_release_is_up_to_date(self): + with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch( + "specify_cli.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" not in output + assert "Up to date" in output + + def test_unknown_installed_still_prints_latest_and_reinstall(self): + with patch("specify_cli._get_installed_version", return_value="unknown"), patch( + "specify_cli.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Current version could not be determined" in output + assert "0.7.4" in output + assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output + + def test_unparseable_tag_routes_to_indeterminate(self): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", + return_value=_mock_urlopen_response({"tag_name": "not-a-version"}), + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert result.exit_code == 0 + assert "Update available" not in output + assert "Up to date" in output + assert "0.7.4" in output + + +class TestFailureCategorization: + def test_urlerror_maps_to_offline(self): + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=urllib.error.URLError("no route to host"), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == "offline or timeout" + + def test_timeout_maps_to_offline(self): + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=TimeoutError(), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == "offline or timeout" + + def test_403_maps_to_rate_limited(self): + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=_http_error(403, "rate limited"), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)" + + @pytest.mark.parametrize("code", [404, 500, 502]) + def test_other_http_uses_code_string(self, code): + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=_http_error(code, "oops"), + ): + tag, reason = _fetch_latest_release_tag() + assert tag is None + assert reason == f"HTTP {code}" + + def test_generic_exception_propagates(self): + # Per research D-006, no catch-all exists; RuntimeError MUST bubble. + with patch( + "specify_cli.urllib.request.urlopen", + side_effect=RuntimeError("boom"), + ): + with pytest.raises(RuntimeError): + _fetch_latest_release_tag() + + +_FAILURE_CASES = [ + ("offline or timeout", urllib.error.URLError("down")), + ("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)), + ("HTTP 500", _http_error(500)), +] + + +class TestUserStory2: + @pytest.mark.parametrize("expected_reason, side_effect", _FAILURE_CASES) + def test_failure_prints_installed_plus_one_line_reason( + self, expected_reason, side_effect + ): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + output = strip_ansi(result.output) + assert "Installed: 0.7.4" in output + if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)": + assert "Could not check latest release: rate limited" in output + assert "GH_TOKEN" in output + assert "GITHUB_TOKEN" in output + else: + assert f"Could not check latest release: {expected_reason}" in output + + @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) + def test_failure_exits_zero(self, _expected_reason, side_effect): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + assert result.exit_code == 0 + + @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) + def test_failure_output_contains_no_traceback_no_url( + self, _expected_reason, side_effect + ): + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + combined = (result.output or "") + (result.stderr or "") + combined = strip_ansi(combined) + assert "Traceback" not in combined + assert "https://api.github.com" not in combined + + +def _capture_request_via_urlopen(): + captured = {} + + def _side_effect(req, timeout=None): + captured["request"] = req + return _mock_urlopen_response({"tag_name": "v0.7.4"}) + + return captured, _side_effect + + +class TestUserStory3: + def test_gh_token_attached_as_bearer_header(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}" + + def test_github_token_used_when_gh_token_unset(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" + + def test_no_authorization_header_when_both_unset(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") is None + + def test_empty_string_gh_token_treated_as_unset(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", "") + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") is None + + def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", " ") + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") is None + + def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", " ") + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + captured, side_effect = _capture_request_via_urlopen() + with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + req = captured["request"] + assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" + + @pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES) + def test_gh_token_never_appears_in_failure_output( + self, _reason, side_effect, monkeypatch + ): + monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + combined = strip_ansi((result.output or "") + (result.stderr or "")) + assert SENTINEL_GH_TOKEN not in combined + + @pytest.mark.parametrize("_reason, side_effect", _FAILURE_CASES) + def test_github_token_never_appears_in_failure_output( + self, _reason, side_effect, monkeypatch + ): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( + "specify_cli.urllib.request.urlopen", side_effect=side_effect + ): + result = runner.invoke(app, ["self", "check"]) + combined = strip_ansi((result.output or "") + (result.stderr or "")) + assert SENTINEL_GITHUB_TOKEN not in combined From ecb3b94b430b8a44ff119a7df32c193c920e3a94 Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:12:44 +0200 Subject: [PATCH 096/184] fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi (#2313) * fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi * chore: remove unused NATIVE_SKILLS_AGENTS constant --- src/specify_cli/__init__.py | 1 - src/specify_cli/agents.py | 3 +- tests/test_extensions.py | 73 +++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 839562f111..9376f9d924 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -928,7 +928,6 @@ def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: # Constants kept for backward compatibility with presets and extensions. DEFAULT_SKILLS_DIR = ".agents/skills" -NATIVE_SKILLS_AGENTS = {"codex", "kimi"} SKILL_DESCRIPTIONS = { "specify": "Create or update feature specifications from natural language descriptions.", "plan": "Generate technical implementation plans from feature specifications.", diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index c5e25a7085..43cbfbe08c 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -282,7 +282,8 @@ def render_skill_command( if not isinstance(frontmatter, dict): frontmatter = {} - if agent_name in {"codex", "kimi"}: + agent_config = self.AGENT_CONFIGS.get(agent_name, {}) + if agent_config.get("extension") == "/SKILL.md": body = self.resolve_skill_placeholders( agent_name, frontmatter, body, project_root ) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e1acb8486b..fdeb5a24ee 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1369,6 +1369,79 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir assert "{ARGS}" not in content assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content + @pytest.mark.parametrize("agent_name,skills_path", [ + ("codex", ".agents/skills"), + ("kimi", ".kimi/skills"), + ("claude", ".claude/skills"), + ("cursor-agent", ".cursor/skills"), + ("trae", ".trae/skills"), + ("agy", ".agents/skills"), + ]) + def test_all_skill_agents_register_commands_with_resolved_placeholders( + self, project_dir, temp_dir, agent_name, skills_path + ): + """All SKILL.md agents must produce fully resolved SKILL.md files when commands are registered.""" + import yaml + + ext_dir = temp_dir / f"ext-{agent_name}" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": f"ext-{agent_name}", + "name": "Scripted Extension", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": f"speckit.ext-{agent_name}.run", + "file": "commands/run.md", + "description": "Scripted command", + } + ] + }, + } + with open(ext_dir / "extension.yml", "w") as f: + yaml.dump(manifest_data, f) + + (ext_dir / "commands" / "run.md").write_text( + "---\n" + "description: Scripted command\n" + "scripts:\n" + ' sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}"\n' + "---\n\n" + "Run {SCRIPT}\n" + "Agent is __AGENT__.\n" + ) + + init_options = project_dir / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(f'{{"ai":"{agent_name}","script":"sh"}}') + + skills_dir = project_dir + for part in skills_path.split("/"): + skills_dir = skills_dir / part + skills_dir.mkdir(parents=True) + + manifest = ExtensionManifest(ext_dir / "extension.yml") + registrar = CommandRegistrar() + registrar.register_commands_for_agent(agent_name, manifest, ext_dir, project_dir) + + skill_dir_name = f"speckit-ext-{agent_name}-run" + skill_file = skills_dir / skill_dir_name / "SKILL.md" + assert skill_file.exists(), f"SKILL.md not created for {agent_name}" + + content = skill_file.read_text() + assert "{SCRIPT}" not in content, f"{{SCRIPT}} not resolved for {agent_name}" + assert "__AGENT__" not in content, f"__AGENT__ not resolved for {agent_name}" + assert "{ARGS}" not in content, f"{{ARGS}} not resolved for {agent_name}" + assert '.specify/scripts/bash/setup-plan.sh' in content + def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir): """Codex alias skills should render their own matching `name:` frontmatter.""" import yaml From f612e1a30d416c0247cdbcf224098cb370e07ae7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:26:44 -0500 Subject: [PATCH 097/184] chore: release 0.7.5, begin 0.7.6.dev0 development (#2322) * chore: bump version to 0.7.5 * chore: begin 0.7.6.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd5e361beb..bd75c336aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ +## [0.7.5] - 2026-04-22 + +### Changed + +- fix: resolve skill placeholders for all SKILL.md agents, not just codex/kimi (#2313) +- feat(cli): add specify self check and self upgrade stub (#2316) +- Update version-guard to v1.1.0 (#2318) +- docs: move community presets from README to docs/community (#2314) +- catalog: add wireframe extension (v0.1.1) (#2262) +- Move community walkthroughs from README to docs/community (#2312) +- docs(readme): list red-team in community-extensions table (#2311) +- feat(catalog): add red-team extension to community catalog (#2306) +- Add superpowers-bridge community extension (#2309) +- feat: implement preset wrap strategy (#2189) +- fix(agents): block directory traversal in command write paths (#2229) (#2296) +- chore: release 0.7.4, begin 0.7.5.dev0 development (#2299) + ## [0.7.4] - 2026-04-21 ### Changed diff --git a/pyproject.toml b/pyproject.toml index fad821feac..6b76d46f99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.5.dev0" +version = "0.7.6.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 3970855797ceb73f3c725e9dd2c9f69ba37a2016 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:40:41 -0500 Subject: [PATCH 098/184] fix: `--force` now overwrites shared infra files during init and upgrade (#2320) * fix: --force now overwrites shared infra files during init and upgrade _install_shared_infra() previously skipped all existing files under .specify/scripts/ and .specify/templates/, regardless of --force. This meant users could never receive upstream fixes to shared scripts or templates after initial project setup. Changes: - Add force parameter to _install_shared_infra(); when True, existing files are overwritten with the latest bundled versions - Wire force=True through specify init --here --force and specify integration upgrade --force call sites - Replace hidden logging.warning with visible console output listing skipped files and suggesting --force - Fix contradictory upgrade docs that claimed --force updated shared infra (it didn't) and warned about overwrites (they didn't happen) - Add 6 tests: unit tests for skip/overwrite/warning behavior, plus end-to-end CLI tests for both --force and non-force paths Fixes #2319 * fix: improve skip warning to suggest specific commands Address review feedback: the generic '--force' suggestion was misleading when _install_shared_infra is called from integration install/switch (which don't have a --force for shared infra). Now points users to the specific commands that can refresh shared infra: 'specify init --here --force' or 'specify integration upgrade --force'. --- docs/upgrade.md | 15 ++-- src/specify_cli/__init__.py | 31 +++++--- tests/integrations/test_cli.py | 137 ++++++++++++++++++++++++++++++--- 3 files changed, 156 insertions(+), 27 deletions(-) diff --git a/docs/upgrade.md b/docs/upgrade.md index 020360d222..16f4b4b0c0 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -53,8 +53,8 @@ When Spec Kit releases new features (like new slash commands or updated template Running `specify init --here --force` will update: - ✅ **Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.) -- ✅ **Script files** (`.specify/scripts/`) -- ✅ **Template files** (`.specify/templates/`) +- ✅ **Script files** (`.specify/scripts/`) — **only with `--force`**; without it, only missing files are added +- ✅ **Template files** (`.specify/templates/`) — **only with `--force`**; without it, only missing files are added - ✅ **Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below** ### What stays safe? @@ -94,7 +94,9 @@ Template files will be merged with existing content and may overwrite existing f Proceed? [y/N] ``` -With `--force`, it skips the confirmation and proceeds immediately. +With `--force`, it skips the confirmation and proceeds immediately. It also **overwrites shared infrastructure files** (`.specify/scripts/` and `.specify/templates/`) with the latest versions from the installed Spec Kit release. + +Without `--force`, shared infrastructure files that already exist are skipped — the CLI will print a warning listing the skipped files so you know which ones were not updated. **Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten. @@ -126,13 +128,14 @@ Or use git to restore it: git restore .specify/memory/constitution.md ``` -### 2. Custom template modifications +### 2. Custom script or template modifications -If you customized any templates in `.specify/templates/`, the upgrade will overwrite them. Back them up first: +If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first: ```bash -# Back up custom templates +# Back up custom templates and scripts cp -r .specify/templates .specify/templates-backup +cp -r .specify/scripts .specify/scripts-backup # After upgrade, merge your changes back manually ``` diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 9376f9d924..31f1b765e1 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -722,12 +722,18 @@ def _install_shared_infra( project_path: Path, script_type: str, tracker: StepTracker | None = None, + force: bool = False, ) -> bool: """Install shared infrastructure files into *project_path*. Copies ``.specify/scripts/`` and ``.specify/templates/`` from the bundled core_pack or source checkout. Tracks all installed files in ``speckit.manifest.json``. + + When *force* is ``True``, existing files are overwritten with the + latest bundled versions. When ``False`` (default), only missing + files are added and existing ones are skipped. + Returns ``True`` on success. """ from .integrations.manifest import IntegrationManifest @@ -752,12 +758,11 @@ def _install_shared_infra( if variant_src.is_dir(): dest_variant = dest_scripts / variant_dir dest_variant.mkdir(parents=True, exist_ok=True) - # Merge without overwriting — only add files that don't exist yet for src_path in variant_src.rglob("*"): if src_path.is_file(): rel_path = src_path.relative_to(variant_src) dst_path = dest_variant / rel_path - if dst_path.exists(): + if dst_path.exists() and not force: skipped_files.append(str(dst_path.relative_to(project_path))) else: dst_path.parent.mkdir(parents=True, exist_ok=True) @@ -778,7 +783,7 @@ def _install_shared_infra( for f in templates_src.iterdir(): if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."): dst = dest_templates / f.name - if dst.exists(): + if dst.exists() and not force: skipped_files.append(str(dst.relative_to(project_path))) else: shutil.copy2(f, dst) @@ -786,10 +791,15 @@ def _install_shared_infra( manifest.record_existing(rel) if skipped_files: - import logging - logging.getLogger(__name__).warning( - "The following shared files already exist and were not overwritten:\n%s", - "\n".join(f" {f}" for f in skipped_files), + console.print( + f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:" + ) + for f in skipped_files: + console.print(f" {f}") + console.print( + "To refresh shared infrastructure, run " + "[cyan]specify init --here --force[/cyan] or " + "[cyan]specify integration upgrade --force[/cyan]." ) manifest.save() @@ -1279,7 +1289,7 @@ def init( # Install shared infrastructure (scripts, templates) tracker.start("shared-infra") - _install_shared_infra(project_path, selected_script, tracker=tracker) + _install_shared_infra(project_path, selected_script, tracker=tracker, force=force) tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_constitution_from_template(project_path, tracker=tracker) @@ -2446,9 +2456,8 @@ def integration_upgrade( selected_script = _resolve_script_type(project_root, script) - # Ensure shared infrastructure is present (safe to run unconditionally; - # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script) + # Ensure shared infrastructure is up to date; --force overwrites existing files. + _install_shared_infra(project_root, selected_script, force=force) if os.name != "nt": ensure_executable_scripts(project_root) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index ff9386d626..9672ab76bf 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -173,13 +173,43 @@ def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path): assert "speckit-specify" in command_file.read_text(encoding="utf-8") assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() - def test_shared_infra_skips_existing_files(self, tmp_path): - """Pre-existing shared files are not overwritten by _install_shared_infra.""" - from typer.testing import CliRunner - from specify_cli import app + def test_shared_infra_skips_existing_files_without_force(self, tmp_path): + """Pre-existing shared files are not overwritten without --force.""" + from specify_cli import _install_shared_infra project = tmp_path / "skip-test" project.mkdir() + (project / ".specify").mkdir() + + # Pre-create a shared script with custom content + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + + # Pre-create a shared template with custom content + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + custom_template = "# user-modified spec-template\n" + (templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8") + + _install_shared_infra(project, "sh", force=False) + + # User's files should be preserved (not overwritten) + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content + assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template + + # Other shared files should still be installed + assert (scripts_dir / "setup-plan.sh").exists() + assert (templates_dir / "plan-template.md").exists() + + def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path): + """Pre-existing shared files ARE overwritten when force=True.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "force-test" + project.mkdir() + (project / ".specify").mkdir() # Pre-create a shared script with custom content scripts_dir = project / ".specify" / "scripts" / "bash" @@ -193,6 +223,67 @@ def test_shared_infra_skips_existing_files(self, tmp_path): custom_template = "# user-modified spec-template\n" (templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8") + _install_shared_infra(project, "sh", force=True) + + # Files should be overwritten with bundled versions + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content + assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template + + # Other shared files should also be installed + assert (scripts_dir / "setup-plan.sh").exists() + assert (templates_dir / "plan-template.md").exists() + + def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys): + """Console warning is displayed when files are skipped.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "warn-test" + project.mkdir() + (project / ".specify").mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + (scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8") + + _install_shared_infra(project, "sh", force=False) + + captured = capsys.readouterr() + assert "already exist and were not updated" in captured.out + assert "specify init --here --force" in captured.out + # Rich may wrap long lines; normalize whitespace for the second command + normalized = " ".join(captured.out.split()) + assert "specify integration upgrade --force" in normalized + + def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys): + """No skip warning when force=True (all files overwritten).""" + from specify_cli import _install_shared_infra + + project = tmp_path / "no-warn-test" + project.mkdir() + (project / ".specify").mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + (scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8") + + _install_shared_infra(project, "sh", force=True) + + captured = capsys.readouterr() + assert "already exist and were not updated" not in captured.out + + def test_init_here_force_overwrites_shared_infra(self, tmp_path): + """E2E: specify init --here --force overwrites shared infra files.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "e2e-force" + project.mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + old_cwd = os.getcwd() try: os.chdir(project) @@ -207,14 +298,40 @@ def test_shared_infra_skips_existing_files(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 + # --force should overwrite the custom file + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content - # User's files should be preserved - assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content - assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template + def test_init_here_without_force_preserves_shared_infra(self, tmp_path): + """E2E: specify init --here (no --force) preserves existing shared infra files.""" + from typer.testing import CliRunner + from specify_cli import app - # Other shared files should still be installed - assert (scripts_dir / "setup-plan.sh").exists() - assert (templates_dir / "plan-template.md").exists() + project = tmp_path / "e2e-no-force" + project.mkdir() + + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + custom_content = "# user-modified common.sh\n" + (scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", + "--integration", "copilot", + "--script", "sh", + "--no-git", + ], input="y\n", catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0 + # Without --force, custom file should be preserved + assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content + # Warning about skipped files should appear + assert "not updated" in result.output class TestForceExistingDirectory: From 9e259e1f8d15d94cb78c6d5fa47af9bdd6c5e6ce Mon Sep 17 00:00:00 2001 From: Kevin Brown <51965072+KevinBrown5280@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:44:06 -0400 Subject: [PATCH 099/184] Update version-guard to v1.2.0 (#2321) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index aa59d27bfa..3a1fe05bfc 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-22T17:54:00Z", + "updated_at": "2026-04-22T21:10:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -2351,8 +2351,8 @@ "id": "version-guard", "description": "Verify tech stack versions against live registries before planning and implementation", "author": "KevinBrown5280", - "version": "1.1.0", - "download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.1.0.zip", + "version": "1.2.0", + "download_url": "https://github.com/KevinBrown5280/spec-kit-version-guard/archive/refs/tags/v1.2.0.zip", "repository": "https://github.com/KevinBrown5280/spec-kit-version-guard", "homepage": "https://github.com/KevinBrown5280/spec-kit-version-guard", "documentation": "https://github.com/KevinBrown5280/spec-kit-version-guard/blob/main/README.md", @@ -2375,7 +2375,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-20T00:00:00Z", - "updated_at": "2026-04-22T17:54:00Z" + "updated_at": "2026-04-22T21:10:00Z" }, "whatif": { "name": "What-if Analysis", @@ -2505,4 +2505,4 @@ "updated_at": "2026-04-13T00:00:00Z" } } -} \ No newline at end of file +} From 709457cec20c724ad5432626a1f0c21e8123cebe Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 23 Apr 2026 19:50:26 +0700 Subject: [PATCH 100/184] Add Memory MD community extension (#2327) --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 468608a6e4..b652aa3611 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ The following community-contributed extensions are available in [`catalog.commun | MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) | | MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) | | Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) | +| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) | | MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 3a1fe05bfc..ecfcbef2c7 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-22T21:10:00Z", + "updated_at": "2026-04-23T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1198,6 +1198,38 @@ "created_at": "2026-04-20T00:00:00Z", "updated_at": "2026-04-20T00:00:00Z" }, + "memory-md": { + "name": "Memory MD", + "id": "memory-md", + "description": "Repository-native durable memory for Spec Kit projects", + "author": "DyanGalih", + "version": "0.6.2", + "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.2.zip", + "repository": "https://github.com/DyanGalih/spec-kit-memory-hub", + "homepage": "https://github.com/DyanGalih/spec-kit-memory-hub", + "documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md", + "changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/docs/memory-workflow-v0.6.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 5, + "hooks": 0 + }, + "tags": [ + "memory", + "workflow", + "docs", + "copilot", + "markdown" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-23T00:00:00Z", + "updated_at": "2026-04-23T00:00:00Z" + }, "memorylint": { "name": "MemoryLint", "id": "memorylint", From b278d66b2c9b7a695186be0aa52ceb5feac65120 Mon Sep 17 00:00:00 2001 From: sha0dow <85430783+D7x7z49@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:12:17 +0800 Subject: [PATCH 101/184] docs(install): add pipx as alternative installation method (#2288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(install): add pipx as alternative installation method - Add pipx commands to README.md installation section - Add note about pipx compatibility to docs/installation.md - Mention pipx persistent installation in docs/quickstart.md - Add pipx upgrade instructions to docs/upgrade.md - Clarify that project has no uv-specific dependencies Refs: https://github.com/github/spec-kit/discussions/2255 * docs(install): address Copilot feedback - update prerequisites and upgrade references for pipx * Update docs/quickstart.md markdownlint’s MD012 (enabled in this repo) flags multiple consecutive blank lines Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update docs/upgrade.md In the Quick Reference table, the label “pipx upgrade” is misleading because the command shown is `pipx install --force ...` (a reinstall). by copilot. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 7 ++++++- docs/installation.md | 9 ++++++++- docs/quickstart.md | 11 +++++++++++ docs/upgrade.md | 9 +++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b652aa3611..88332922d5 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX # Or install latest from main (may include unreleased changes) uv tool install specify-cli --from git+https://github.com/github/spec-kit.git + +# Alternative: using pipx (also works) +pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z +pipx install git+https://github.com/github/spec-kit.git ``` Then verify the correct version is installed: @@ -89,6 +93,7 @@ To upgrade Specify, see the [Upgrade Guide](./docs/upgrade.md) for detailed inst ```bash uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z +# pipx users: pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z ``` #### Option 2: One-time Usage @@ -426,7 +431,7 @@ Our research and experimentation focus on: - **Linux/macOS/Windows** - [Supported](#-supported-ai-coding-agent-integrations) AI coding agent. -- [uv](https://docs.astral.sh/uv/) for package management +- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation - [Python 3.11+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) diff --git a/docs/installation.md b/docs/installation.md index ed253902af..c99810f706 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -4,7 +4,7 @@ - **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL) - AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev) -- [uv](https://docs.astral.sh/uv/) for package management +- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pypa.github.io/pipx/) for persistent installation - [Python 3.11+](https://www.python.org/downloads/) - [Git](https://git-scm.com/downloads) @@ -24,6 +24,13 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init ``` +> [!NOTE] +> For a persistent installation, `pipx` works equally well: +> ```bash +> pipx install git+https://github.com/github/spec-kit.git@vX.Y.Z +> ``` +> The project uses a standard `hatchling` build backend and has no uv-specific dependencies. + Or initialize in the current directory: ```bash diff --git a/docs/quickstart.md b/docs/quickstart.md index 4b2c3c8807..5c0f009306 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -22,6 +22,17 @@ uvx --from git+https://github.com/github/spec-kit.git specify init [!NOTE] +> You can also install the CLI persistently with `pipx`: +> ```bash +> pipx install git+https://github.com/github/spec-kit.git +> ``` +> After installing with `pipx`, run `specify` directly instead of `uvx --from ... specify`, for example: +> ```bash +> specify init +> specify init . +> ``` + Pick script type explicitly (optional): ```bash diff --git a/docs/upgrade.md b/docs/upgrade.md index 16f4b4b0c0..934be675e2 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -9,6 +9,7 @@ | What to Upgrade | Command | When to Use | |----------------|---------|-------------| | **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files | +| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release | | **Project Files** | `specify init --here --force --ai ` | Update slash commands, templates, and scripts in your project | | **Both** | Run CLI upgrade, then project update | Recommended for major version updates | @@ -34,6 +35,14 @@ Specify the desired release tag: uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot ``` +### If you installed with `pipx` + +Upgrade to a specific release: + +```bash +pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z +``` + ### Verify the upgrade ```bash From 8fefd2a532d15a94fa7852791c2ef6071ed61e05 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:26:59 -0500 Subject: [PATCH 102/184] feat(copilot): support `--integration-options="--skills"` for skills-based scaffolding (#2324) * Initial plan * feat(copilot): add --skills flag for skills-based scaffolding Add --skills integration option to CopilotIntegration that scaffolds commands as speckit-/SKILL.md under .github/skills/ instead of the default .agent.md + .prompt.md layout. - Add options() with --skills flag (default=False) - Branch setup() between default and skills modes - Add post_process_skill_content() for Copilot-specific mode: field - Adjust build_command_invocation() for skills mode (/speckit-) - Update dispatch_command() with skills mode detection - Parse --integration-options during init command - Add 22 new skills-mode tests - All 15 existing default-mode tests continue to pass Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a4903fab-64ff-46c3-8eb8-a47f495a70c0 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * docs(AGENTS.md): document Copilot --skills option Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a4903fab-64ff-46c3-8eb8-a47f495a70c0 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: address PR #2324 review feedback - Reset _skills_mode at start of setup() to prevent singleton state leak - Tighten skills auto-detection to require speckit-*/SKILL.md (not any non-empty .github/skills/ directory) - Add copilot_skill_mode to init next-steps so skills mode renders /speckit-plan instead of /speckit.plan - Fix docstring quoting to match actual unquoted output - Add 4 tests covering singleton reset, auto-detection false positive, speckit layout detection, and next-steps skill syntax - Fix skipped test_invalid_metadata_error_returns_unknown by simulating InvalidMetadataError on Python versions that lack it * fix: inline skills prompt in dispatch_command auto-detection path build_command_invocation() reads self._skills_mode which stays False when skills mode is only auto-detected from the project layout. Inline the /speckit- prompt construction so dispatch_command() sends the correct prompt regardless of how skills mode was detected. Also strengthen test_dispatch_detects_speckit_skills_layout to assert the -p prompt contains /speckit-plan and the user args. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- AGENTS.md | 24 +- src/specify_cli/__init__.py | 19 +- .../integrations/copilot/__init__.py | 196 +++++++- .../integrations/test_integration_copilot.py | 419 ++++++++++++++++++ tests/test_upgrade.py | 25 +- 5 files changed, 658 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b076dc384..7adfd1d12e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,13 +264,13 @@ The base classes handle most work automatically. Override only when the agent de | Override | When to use | Example | |---|---|---| | `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` | -| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag | -| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` | +| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag | +| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-/SKILL.md` (skills mode) | | `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files | **Example — Copilot (fully custom `setup`):** -Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation. +Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation. ### 7. Update Devcontainer files (Optional) @@ -391,6 +391,24 @@ Implementation: Extends `IntegrationBase` with custom `setup()` method that: 2. Generates companion `.prompt.md` files 3. Merges VS Code settings +**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout +via `--integration-options="--skills"`. When enabled: +- Commands are scaffolded as `speckit-/SKILL.md` under `.github/skills/` +- No companion `.prompt.md` files are generated +- No `.vscode/settings.json` merge +- `post_process_skill_content()` injects a `mode: speckit.` frontmatter field +- `build_command_invocation()` returns `/speckit-` instead of bare args + +The two modes are mutually exclusive — a project uses one or the other: + +```bash +# Default mode: .agent.md agents + .prompt.md companions + settings merge +specify init my-project --integration copilot + +# Skills mode: speckit-/SKILL.md under .github/skills/ +specify init my-project --integration copilot --integration-options="--skills" +``` + ### Forge Integration Forge has special frontmatter and argument requirements: diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 31f1b765e1..743ceb9954 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1268,6 +1268,12 @@ def init( integration_parsed_options["commands_dir"] = ai_commands_dir if ai_skills: integration_parsed_options["skills"] = True + # Parse --integration-options and merge into parsed_options so + # flags like --skills reach the integration's setup(). + if integration_options: + extra = _parse_integration_options(resolved_integration, integration_options) + if extra: + integration_parsed_options.update(extra) resolved_integration.setup( project_path, manifest, @@ -1393,8 +1399,10 @@ def init( } # Ensure ai_skills is set for SkillsIntegration so downstream # tools (extensions, presets) emit SKILL.md overrides correctly. + # Also set for integrations running in skills mode (e.g. Copilot + # with --skills). from .integrations.base import SkillsIntegration as _SkillsPersist - if isinstance(resolved_integration, _SkillsPersist): + if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): init_opts["ai_skills"] = True save_init_options(project_path, init_opts) @@ -1506,7 +1514,7 @@ def init( # Determine skill display mode for the next-steps panel. # Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax. from .integrations.base import SkillsIntegration as _SkillsInt - _is_skills_integration = isinstance(resolved_integration, _SkillsInt) + _is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration) claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration) @@ -1514,7 +1522,8 @@ def init( agy_skill_mode = selected_ai == "agy" and _is_skills_integration trae_skill_mode = selected_ai == "trae" cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) - 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 + copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration + 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 if codex_skill_mode and not ai_skills: # Integration path installed skills; show the helpful notice @@ -1535,7 +1544,7 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" - if cursor_agent_skill_mode: + if cursor_agent_skill_mode or copilot_skill_mode: return f"/speckit-{name}" return f"/speckit.{name}" @@ -2166,7 +2175,7 @@ def _update_init_options_for_integration( opts["context_file"] = integration.context_file if script_type: opts["script"] = script_type - if isinstance(integration, SkillsIntegration): + if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): opts["ai_skills"] = True else: opts.pop("ai_skills", None) diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 45ec6f3532..5c4d0e5410 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -5,6 +5,10 @@ - Each command gets a companion ``.prompt.md`` file in ``.github/prompts/`` - Installs ``.vscode/settings.json`` with prompt file recommendations - Context file lives at ``.github/copilot-instructions.md`` + +When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds +commands as ``speckit-/SKILL.md`` directories under ``.github/skills/`` +instead. The two modes are mutually exclusive. """ from __future__ import annotations @@ -16,7 +20,7 @@ from pathlib import Path from typing import Any -from ..base import IntegrationBase +from ..base import IntegrationBase, IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest @@ -44,12 +48,40 @@ def _allow_all() -> bool: return True +class _CopilotSkillsHelper(SkillsIntegration): + """Internal helper used when Copilot is scaffolded in skills mode. + + Not registered in the integration registry — only used as a delegate + by ``CopilotIntegration`` when ``--skills`` is passed. + """ + + key = "copilot" + config = { + "name": "GitHub Copilot", + "folder": ".github/", + "commands_subdir": "skills", + "install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli", + "requires_cli": False, + } + registrar_config = { + "dir": ".github/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = ".github/copilot-instructions.md" + + class CopilotIntegration(IntegrationBase): """Integration for GitHub Copilot (VS Code IDE + CLI). The IDE integration (``requires_cli: False``) installs ``.agent.md`` command files. Workflow dispatch additionally requires the ``copilot`` CLI to be installed separately. + + When ``--skills`` is passed via ``--integration-options``, commands + are scaffolded as ``speckit-/SKILL.md`` under ``.github/skills/`` + instead of the default ``.agent.md`` + ``.prompt.md`` layout. """ key = "copilot" @@ -68,6 +100,20 @@ class CopilotIntegration(IntegrationBase): } context_file = ".github/copilot-instructions.md" + # Mutable flag set by setup() — indicates the active scaffolding mode. + _skills_mode: bool = False + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=False, + help="Scaffold commands as agent skills (speckit-/SKILL.md) instead of .agent.md files", + ), + ] + def build_exec_args( self, prompt: str, @@ -92,7 +138,19 @@ def build_exec_args( return args def build_command_invocation(self, command_name: str, args: str = "") -> str: - """Copilot agents are not slash-commands — just return the args as prompt.""" + """Build the native invocation for a Copilot command. + + Default mode: agents are not slash-commands — return args as prompt. + Skills mode: ``/speckit-`` slash-command dispatch. + """ + if self._skills_mode: + stem = command_name + if "." in stem: + stem = stem.rsplit(".", 1)[-1] + invocation = f"/speckit-{stem}" + if args: + invocation = f"{invocation} {args}" + return invocation return args or "" def dispatch_command( @@ -110,19 +168,37 @@ def dispatch_command( Copilot ``.agent.md`` files are agents, not skills. The CLI selects them with ``--agent `` and the prompt is just the user's arguments. + + In skills mode, the prompt includes the skill invocation + (``/speckit-``). """ import subprocess stem = command_name if "." in stem: stem = stem.rsplit(".", 1)[-1] - agent_name = f"speckit.{stem}" - prompt = args or "" - cli_args = [ - "copilot", "-p", prompt, - "--agent", agent_name, - ] + # Detect skills mode from project layout when not set via setup() + skills_mode = self._skills_mode + if not skills_mode and project_root: + skills_dir = project_root / ".github" / "skills" + if skills_dir.is_dir(): + skills_mode = any( + d.is_dir() and (d / "SKILL.md").is_file() + for d in skills_dir.glob("speckit-*") + ) + + if skills_mode: + prompt = f"/speckit-{stem}" + if args: + prompt = f"{prompt} {args}" + else: + agent_name = f"speckit.{stem}" + prompt = args or "" + + cli_args = ["copilot", "-p", prompt] + if not skills_mode: + cli_args.extend(["--agent", agent_name]) if _allow_all(): cli_args.append("--yolo") if model: @@ -168,6 +244,59 @@ def command_filename(self, template_name: str) -> str: """Copilot commands use ``.agent.md`` extension.""" return f"speckit.{template_name}.agent.md" + def post_process_skill_content(self, content: str) -> str: + """Inject Copilot-specific ``mode:`` field into SKILL.md frontmatter. + + Inserts ``mode: speckit.`` before the closing ``---`` so + Copilot can associate the skill with its agent mode. + """ + lines = content.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 content # 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 content + + # 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( self, project_root: Path, @@ -177,10 +306,24 @@ def setup( ) -> list[Path]: """Install copilot commands, companion prompts, and VS Code settings. - Uses base class primitives to: read templates, process them - (replace placeholders, strip script blocks, rewrite paths), - write as ``.agent.md``, then add companion prompts and VS Code settings. + When ``parsed_options["skills"]`` is truthy, delegates to skills + scaffolding (``speckit-/SKILL.md`` under ``.github/skills/``). + Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout. """ + parsed_options = parsed_options or {} + self._skills_mode = bool(parsed_options.get("skills")) + if self._skills_mode: + return self._setup_skills(project_root, manifest, parsed_options, **opts) + return self._setup_default(project_root, manifest, parsed_options, **opts) + + def _setup_default( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Default mode: .agent.md + .prompt.md + VS Code settings merge.""" project_root_resolved = project_root.resolve() if manifest.project_root != project_root_resolved: raise ValueError( @@ -252,6 +395,37 @@ def setup( return created + def _setup_skills( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Skills mode: delegate to ``_CopilotSkillsHelper`` then post-process.""" + helper = _CopilotSkillsHelper() + created = SkillsIntegration.setup( + helper, project_root, manifest, parsed_options, **opts + ) + + # Post-process generated skill files with Copilot-specific frontmatter + skills_dir = helper.skills_dest(project_root).resolve() + for path in created: + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + + content = path.read_text(encoding="utf-8") + updated = self.post_process_skill_content(content) + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + + return created + def _vscode_settings_path(self) -> Path | None: """Return path to the bundled vscode-settings.json template.""" tpl_dir = self.shared_templates_dir() diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 642b1e5300..462f6d120a 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -3,6 +3,8 @@ import json import os +import yaml + from specify_cli.integrations import get_integration from specify_cli.integrations.manifest import IntegrationManifest @@ -275,3 +277,420 @@ def test_complete_file_inventory_ps(self, tmp_path): f"Missing: {sorted(set(expected) - set(actual))}\n" f"Extra: {sorted(set(actual) - set(expected))}" ) + + +class TestCopilotSkillsMode: + """Tests for Copilot integration in --skills mode.""" + + _SKILL_COMMANDS = [ + "analyze", "checklist", "clarify", "constitution", + "implement", "plan", "specify", "tasks", "taskstoissues", + ] + + def _make_copilot(self): + from specify_cli.integrations.copilot import CopilotIntegration + return CopilotIntegration() + + def _setup_skills(self, copilot, tmp_path): + m = IntegrationManifest("copilot", tmp_path) + created = copilot.setup(tmp_path, m, parsed_options={"skills": True}) + return created, m + + # -- Options ---------------------------------------------------------- + + def test_options_include_skills_flag(self): + copilot = get_integration("copilot") + opts = copilot.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 1 + assert skills_opts[0].is_flag is True + assert skills_opts[0].default is False + + # -- Skills directory structure --------------------------------------- + + def test_skills_creates_skill_files(self, tmp_path): + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + assert len(created) > 0 + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + assert f.exists() + assert f.parent.name.startswith("speckit-") + + def test_skills_directory_under_github_skills(self, tmp_path): + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skills_dir = tmp_path / ".github" / "skills" + assert skills_dir.is_dir() + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + assert f.resolve().parent.parent == skills_dir.resolve(), ( + f"{f} is not under {skills_dir}" + ) + + def test_skills_directory_structure(self, tmp_path): + """Each command produces speckit-/SKILL.md.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + expected_commands = set(self._SKILL_COMMANDS) + actual_commands = set() + for f in skill_files: + skill_dir_name = f.parent.name + assert skill_dir_name.startswith("speckit-") + actual_commands.add(skill_dir_name.removeprefix("speckit-")) + assert actual_commands == expected_commands + + # -- No companion files in skills mode -------------------------------- + + def test_skills_no_prompt_md_companions(self, tmp_path): + """Skills mode must not generate .prompt.md companion files.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + prompt_files = [f for f in created if f.name.endswith(".prompt.md")] + assert prompt_files == [] + prompts_dir = tmp_path / ".github" / "prompts" + if prompts_dir.exists(): + assert list(prompts_dir.iterdir()) == [] + + def test_skills_no_vscode_settings(self, tmp_path): + """Skills mode must not create or merge .vscode/settings.json.""" + copilot = self._make_copilot() + self._setup_skills(copilot, tmp_path) + settings = tmp_path / ".vscode" / "settings.json" + assert not settings.exists() + + def test_skills_no_agent_md_files(self, tmp_path): + """Skills mode must not produce .agent.md files.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + agent_files = [f for f in created if f.name.endswith(".agent.md")] + assert agent_files == [] + + # -- Frontmatter structure -------------------------------------------- + + def test_skill_frontmatter_structure(self, tmp_path): + """SKILL.md must have name, description, compatibility, metadata.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert content.startswith("---\n"), f"{f} missing frontmatter" + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert "name" in fm, f"{f} frontmatter missing 'name'" + assert "description" in fm, f"{f} frontmatter missing 'description'" + assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'" + assert "metadata" in fm, f"{f} frontmatter missing 'metadata'" + assert fm["metadata"]["author"] == "github-spec-kit" + + # -- Copilot-specific post-processing --------------------------------- + + def test_post_process_skill_content_injects_mode(self): + """post_process_skill_content() should inject mode: field.""" + copilot = self._make_copilot() + content = ( + "---\n" + 'name: "speckit-plan"\n' + 'description: "Plan workflow"\n' + "---\n" + "\nBody content\n" + ) + updated = copilot.post_process_skill_content(content) + assert "mode: speckit.plan" in updated + + def test_post_process_idempotent(self): + """post_process_skill_content() must be idempotent.""" + copilot = self._make_copilot() + content = ( + "---\n" + 'name: "speckit-plan"\n' + 'description: "Plan workflow"\n' + "---\n" + "\nBody content\n" + ) + first = copilot.post_process_skill_content(content) + second = copilot.post_process_skill_content(first) + assert first == second + + def test_skills_have_mode_in_frontmatter(self, tmp_path): + """Generated SKILL.md files should have mode: field from post-processing.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + fm = yaml.safe_load(parts[1]) + assert "mode" in fm, f"{f} frontmatter missing 'mode'" + # mode should be speckit. + skill_dir_name = f.parent.name + stem = skill_dir_name.removeprefix("speckit-") + assert fm["mode"] == f"speckit.{stem}" + + # -- Template processing ---------------------------------------------- + + def test_skills_templates_are_processed(self, tmp_path): + """Skill body must have placeholders replaced.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + assert len(skill_files) > 0 + for f in skill_files: + content = f.read_text(encoding="utf-8") + assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}" + assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" + assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" + + def test_skill_body_has_content(self, tmp_path): + """Each SKILL.md body should contain template content.""" + copilot = self._make_copilot() + created, _ = self._setup_skills(copilot, tmp_path) + skill_files = [f for f in created if f.name == "SKILL.md"] + for f in skill_files: + content = f.read_text(encoding="utf-8") + parts = content.split("---", 2) + body = parts[2].strip() if len(parts) >= 3 else "" + assert len(body) > 0, f"{f} has empty body" + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan skill must reference copilot's context file.""" + copilot = self._make_copilot() + self._setup_skills(copilot, tmp_path) + plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert copilot.context_file in content + assert "__CONTEXT_FILE__" not in content + + # -- Manifest tracking ------------------------------------------------ + + def test_all_files_tracked_in_manifest(self, tmp_path): + copilot = self._make_copilot() + created, m = self._setup_skills(copilot, tmp_path) + for f in created: + rel = f.resolve().relative_to(tmp_path.resolve()).as_posix() + assert rel in m.files, f"{rel} not tracked in manifest" + + # -- Install/uninstall roundtrip -------------------------------------- + + def test_install_uninstall_roundtrip(self, tmp_path): + copilot = self._make_copilot() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.install(tmp_path, m, parsed_options={"skills": True}) + assert len(created) > 0 + m.save() + for f in created: + assert f.exists() + removed, skipped = copilot.uninstall(tmp_path, m) + assert len(removed) == len(created) + assert skipped == [] + + def test_modified_file_survives_uninstall(self, tmp_path): + copilot = self._make_copilot() + m = IntegrationManifest("copilot", tmp_path) + created = copilot.install(tmp_path, m, parsed_options={"skills": True}) + m.save() + modified_file = created[0] + modified_file.write_text("user modified this", encoding="utf-8") + removed, skipped = copilot.uninstall(tmp_path, m) + assert modified_file.exists() + assert modified_file in skipped + + # -- build_command_invocation ----------------------------------------- + + def test_build_command_invocation_skills_mode(self): + copilot = self._make_copilot() + copilot._skills_mode = True + assert copilot.build_command_invocation("speckit.plan") == "/speckit-plan" + assert copilot.build_command_invocation("plan") == "/speckit-plan" + assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args" + + def test_build_command_invocation_default_mode(self): + copilot = self._make_copilot() + assert copilot.build_command_invocation("plan", "my args") == "my args" + assert copilot.build_command_invocation("plan") == "" + + # -- Context section --------------------------------------------------- + + def test_skills_setup_upserts_context_section(self, tmp_path): + copilot = self._make_copilot() + self._setup_skills(copilot, tmp_path) + ctx_path = tmp_path / copilot.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + + # -- CLI integration test --------------------------------------------- + + def test_init_with_integration_options_skills(self, tmp_path): + """specify init --integration copilot --integration-options='--skills' scaffolds skills.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "copilot-skills" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + skills_dir = project / ".github" / "skills" + assert skills_dir.is_dir(), "Skills directory was not created" + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists(), "speckit-plan/SKILL.md not found" + # Verify no default-mode artifacts + assert not (project / ".github" / "agents").exists() + assert not (project / ".github" / "prompts").exists() + assert not (project / ".vscode" / "settings.json").exists() + + def test_complete_file_inventory_skills_sh(self, tmp_path): + """Every file produced by specify init --integration copilot --integration-options='--skills' --script sh.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "inventory-skills-sh" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()) + expected = sorted([ + # Skill files + *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], + # Context file + ".github/copilot-instructions.md", + # Integration metadata + ".specify/init-options.json", + ".specify/integration.json", + ".specify/integrations/copilot.manifest.json", + ".specify/integrations/speckit.manifest.json", + # Scripts (sh) + ".specify/scripts/bash/check-prerequisites.sh", + ".specify/scripts/bash/common.sh", + ".specify/scripts/bash/create-new-feature.sh", + ".specify/scripts/bash/setup-plan.sh", + # Templates + ".specify/templates/checklist-template.md", + ".specify/templates/constitution-template.md", + ".specify/templates/plan-template.md", + ".specify/templates/spec-template.md", + ".specify/templates/tasks-template.md", + ".specify/memory/constitution.md", + # Bundled workflow + ".specify/workflows/speckit/workflow.yml", + ".specify/workflows/workflow-registry.json", + ]) + assert actual == expected, ( + f"Missing: {sorted(set(expected) - set(actual))}\n" + f"Extra: {sorted(set(actual) - set(expected))}" + ) + + # -- Singleton leak: _skills_mode must reset -------------------------- + + def test_skills_mode_resets_on_default_setup(self, tmp_path): + """setup() with skills=True then without must reset _skills_mode.""" + copilot = self._make_copilot() + + # First call: skills mode + (tmp_path / "proj1").mkdir() + m1 = IntegrationManifest("copilot", tmp_path / "proj1") + copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True}) + assert copilot._skills_mode is True + + # Second call: default mode (no skills option) + (tmp_path / "proj2").mkdir() + m2 = IntegrationManifest("copilot", tmp_path / "proj2") + copilot.setup(tmp_path / "proj2", m2) + assert copilot._skills_mode is False + + # build_command_invocation must use default (dotted) mode + assert copilot.build_command_invocation("plan", "args") == "args" + + # -- Auto-detection must ignore unrelated .github/skills/ ------------- + + def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path): + """dispatch_command() must not treat unrelated .github/skills/ as skills mode.""" + copilot = self._make_copilot() + # Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training) + unrelated = tmp_path / ".github" / "skills" / "introduction-to-github" + unrelated.mkdir(parents=True) + (unrelated / "README.md").write_text("# GitHub Skills training\n") + + # Should NOT detect skills mode — cli_args should contain --agent + import unittest.mock as mock + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="") + copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False) + call_args = mock_run.call_args[0][0] + assert "--agent" in call_args, ( + f"Expected --agent in cli_args but got: {call_args}" + ) + assert "speckit.plan" in call_args + + def test_dispatch_detects_speckit_skills_layout(self, tmp_path): + """dispatch_command() detects speckit-*/SKILL.md as skills mode.""" + copilot = self._make_copilot() + skill_dir = tmp_path / ".github" / "skills" / "speckit-plan" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n") + + import unittest.mock as mock + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="") + copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False) + call_args = mock_run.call_args[0][0] + assert "--agent" not in call_args, ( + f"Skills mode should not use --agent, got: {call_args}" + ) + prompt = call_args[call_args.index("-p") + 1] + assert "/speckit-plan" in prompt, ( + f"Skills mode prompt should invoke /speckit-plan, got: {prompt}" + ) + assert "my args" in prompt, ( + f"Skills mode prompt should preserve user args, got: {prompt}" + ) + + # -- Next-steps display for Copilot skills mode ----------------------- + + def test_init_skills_next_steps_show_skill_syntax(self, tmp_path): + """specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan.""" + from typer.testing import CliRunner + from specify_cli import app + project = tmp_path / "copilot-nextsteps" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "copilot", + "--integration-options", "--skills", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, f"init failed: {result.output}" + # Skills mode should show /speckit-plan (hyphenated) + assert "/speckit-plan" in result.output, ( + f"Expected /speckit-plan in next steps but got:\n{result.output}" + ) + # Must NOT show the dotted /speckit.plan form + assert "/speckit.plan" not in result.output, ( + f"Should not show /speckit.plan in skills mode:\n{result.output}" + ) \ No newline at end of file diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 96aa627874..28a0ce6414 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -100,12 +100,25 @@ class TestInstalledVersion: def test_invalid_metadata_error_returns_unknown(self): invalid_metadata_error = getattr(importlib.metadata, "InvalidMetadataError", None) if invalid_metadata_error is None: - pytest.skip("InvalidMetadataError is not available on this Python version") - with patch( - "importlib.metadata.version", - side_effect=invalid_metadata_error("bad metadata"), - ): - assert _get_installed_version() == "unknown" + # Python versions without InvalidMetadataError: simulate with a + # custom exception to verify the guarded except path works. + class _FakeInvalidMetadataError(Exception): + pass + invalid_metadata_error = _FakeInvalidMetadataError + # Patch the attribute onto importlib.metadata so the production + # getattr() finds it during this test. + with patch.object(importlib.metadata, "InvalidMetadataError", invalid_metadata_error, create=True): + with patch( + "importlib.metadata.version", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert _get_installed_version() == "unknown" + else: + with patch( + "importlib.metadata.version", + side_effect=invalid_metadata_error("bad metadata"), + ): + assert _get_installed_version() == "unknown" class TestNormalizeTag: From a067d4c2e3a56f68185769e43a44f53b1282562d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:07:52 -0500 Subject: [PATCH 103/184] feat(presets): Composition strategies (prepend, append, wrap) for templates, commands, and scripts (#2133) * fix: rebase onto upstream/main, resolve conflicts with PR #2189 upstream/main merged PR #2189 (wrap-only strategy) which overlaps with our comprehensive composition strategies (prepend/append/wrap). Resolved conflicts keeping our implementation as source of truth: - README: keep our future considerations (composition is now fully implemented, not a future item) - presets.py: keep our composition architecture (_reconcile_composed_commands, collect_all_layers, resolve_content) while preserving #2189's _substitute_core_template which is used by agents.py for skill generation - tests: keep both test sets (our composition tests + #2189's wrap tests), removed TestReplayWrapsForCommand and TestInstallRemoveWrapLifecycle which test the superseded _replay_wraps_for_command API; our composition tests cover equivalent scenarios - Restored missing _unregister_commands call in remove() that was lost during #2189 merge * fix: re-create skill directory in _reconcile_skills after removal After _unregister_skills removes a skill directory, _register_skills skips writing because the dir no longer passes the is_dir() check. Fix by ensuring the skill subdirectory exists before calling _register_skills so the next winning preset's content gets registered. Fixes the Claude E2E failure where removing a top-priority override preset left skill-based agents without any SKILL.md file. * fix: address twenty-third round of Copilot PR review feedback - Protect reconciliation in remove(): wrap _reconcile_composed_commands and _reconcile_skills in try/except so failures emit a warning instead of leaving the project in an inconsistent state - Protect reconciliation in install(): same pattern for post-install reconciliation so partial installs don't lack cleanup - Inherit scripts/agent_scripts from base frontmatter: when composing commands, merge scripts and agent_scripts keys from the base command's frontmatter into the top layer's frontmatter if missing, preventing composed commands from losing required script references - Add tier-5 bundled core fallback to collect_all_layers(): check the bundled core_pack (wheel) or repo-root templates (source checkout) when .specify/templates/ doesn't contain the core file, matching resolve()'s tier-5 fallback so composition can always find a base layer * fix: address twenty-fourth round of Copilot PR review feedback - Use yaml.safe_load for frontmatter parsing in resolve_content instead of CommandRegistrar.parse_frontmatter which uses naive find('---',3); strip strategy key from final frontmatter to prevent leaking internal composition directives into rendered agent command files - Filter _reconcile_skills to specific commands: use _FilteredManifest wrapper so only the commands being reconciled get their skills updated, preventing accidental overwrites of other commands' skills that may be owned by higher-priority presets * fix: address twenty-fifth round of Copilot PR review feedback - Support legacy command-frontmatter strategy: when preset.yml doesn't declare a strategy, check the command file's YAML frontmatter for strategy: wrap as a fallback so legacy wrap presets participate in composition and multi-preset chaining - Guard skill dir creation in _reconcile_skills: only re-create the skill directory if the skill was previously managed (listed in some preset's registered_skills), avoiding creation of new skill dirs that _register_skills would normally skip * fix: add explanatory comment to empty except in legacy frontmatter parsing * fix: address twenty-sixth round of Copilot PR review feedback - Unregister stale commands when composition fails: when resolve_content returns None during reconciliation (base layer removed), unregister the command from non-skill agents and emit a warning - Load extension aliases during reconciliation: _register_command_from_path now checks extension.yml for aliases when the winning layer is an extension, so alias files are restored after preset removal - Use line-based fence detection for legacy frontmatter strategy fallback: scan for --- on its own line instead of split('---',2) to avoid mis-parsing YAML values containing --- * fix: address twenty-seventh round of Copilot PR review feedback - Handle non-preset winners in _reconcile_skills: when the winning layer is core/extension/project-override, restore skills via _unregister_skills so skill-based agents stay consistent with the priority stack - Update base_frontmatter_text on replace layers: when a higher-priority replace layer occurs during composition, update both top and base frontmatter so scripts/agent_scripts inheritance reflects the effective base beneath the top composed layer * fix: address twenty-eighth round of Copilot PR review feedback - Parse only interior lines in _parse_fm_yaml: use lines[1:-1] instead of filtering all --- lines, preventing corruption when YAML values contain a line that is exactly --- - Omit empty frontmatter: skip re-rendering when top_fm is empty dict to avoid emitting ---/{}/--- for intentionally empty frontmatter - Update scaffold wrap comment: mention both {CORE_TEMPLATE} and $CORE_SCRIPT placeholders for templates/commands vs scripts - Clarify shell composition scope in ARCHITECTURE.md: note that bash/PS1 resolve_template_content only handles templates; command/script composition is handled by the Python resolver * fix: address twenty-ninth round of Copilot PR review feedback - Fix TestCollectAllLayers docstring: reference collect_all_layers() - Add default/unknown strategy handling in bash/PS1 composition: error on unrecognized strategy values instead of silently skipping - Fix comment: .composed/ is a persistent dir, not temporary - Fix comment: legacy fallback checks all valid strategies, not just wrap - Cache PresetRegistry in _reconcile_skills: build presets_by_priority once instead of constructing registry per-command * fix: address thirtieth round of Copilot PR review feedback - Guard legacy frontmatter fallback: only check command file frontmatter for strategy when the manifest entry doesn't explicitly include the strategy key, preventing override of manifest-declared strategies - Document rollback limitation: note that mid-registration failures may leave orphaned agent command files since partial progress isn't captured by the local vars * fix: handle project override skills and extension context in reconciliation * fix: add comment to empty except in extension registration fallback * fix: filter extension commands in reconciliation and fix type annotation * fix: filter extension commands from post-install reconciliation Apply the same extension-installed check used in _register_commands to the reconciliation command list, preventing reconciliation from registering commands for extensions that are not installed. * fix: skip convention fallback for explicit file paths and add stem fallback to tier-5 When a preset manifest provides an explicit file path that does not exist, skip the convention-based fallback to avoid masking typos. Also add speckit. to .md fallback in tier-5 bundled/source core lookup for consistency with tier-4. * fix: scan past non-replace layers to find base in resolve_content The base-finding scan now skips non-replace layers below a replace layer instead of stopping at the first non-replace. This fixes the case where a low-priority append/prepend layer sits below a replace that should serve as the base for composition. * fix: add context_note to non-skill agent registration for extensions Add context_note parameter to register_commands_for_non_skill_agents and pass extension name/id during reconciliation so rendered command files preserve the extension-specific context markers. * fix: Optional type, rollback safety, and override skill restoration - Fix context_note type to Optional[str] - Wrap shutil.rmtree in try/except during install rollback - Separate override-backed skills from core/extension in _reconcile_skills * fix: align bash/PS1 base-finding with Python resolver Rewrite bash and PowerShell composition loops to find the effective base replace layer first (scanning bottom-up, skipping non-replace layers below it), then compose only from the base upward. This prevents evaluation of irrelevant lower layers (e.g. a wrap with no placeholder below a replace) and matches resolve_content behavior. * fix: PS1 no-python warning, integration hook for override skills, alias cleanup - Warn when no Python 3 found in PS1 and presets use composition strategies - Apply post_process_skill_content integration hook when restoring override-backed skills so agent-specific flags are preserved - Unregister command aliases alongside primary name when composition fails to prevent orphaned alias files * fix: include aliases in removed_cmd_names during preset removal Read aliases from preset manifest before deleting pack_dir so alias command files are included in unregistration and reconciliation. * fix: add comment to empty except in alias extraction during removal * fix: scan top-down for effective base in all resolvers Change base-finding to scan from highest priority downward to find the nearest replace layer, then compose only layers above it. Prevents evaluation of irrelevant lower layers (e.g. a wrap without placeholder below a higher-priority replace) across Python, bash, and PowerShell. * fix: align CLI composition chain display with top-down base-finding Show only contributing layers (base and above) in preset resolve output, matching resolve_content top-down semantics. Layers below the effective base are omitted since they do not contribute. * fix: guard corrupted registry entries and make manifest authoritative - Add isinstance(meta, dict) guard in bash registry parsing so corrupted entries are skipped instead of breaking priority ordering - Only use convention-based file lookup when the manifest does not list the requested template, making preset.yml authoritative and preventing stray on-disk files from creating unintended layers * fix: align resolve() with manifest file paths and match extension context_note - Update resolve() preset tier to consult manifest file paths before convention-based lookup, matching collect_all_layers behavior - Use exact extension context_note format matching extensions.CommandRegistrar - Update test to declare template in manifest (authoritative manifest) * revert: restore resolve() convention-based behavior for backwards compatibility resolve() is the existing public API used by shell scripts and other callers. Changing it to manifest-authoritative breaks backward compat for presets that rely on convention-based file lookup. Only the new collect_all_layers/resolve_content path uses manifest-authoritative logic. * fix: only pre-compose when this preset is the top composing layer Skip composition in _register_commands when a higher-priority replace layer already wins for the command. Register the raw file instead and let reconciliation write the correct final content. * fix: deduplicate PyYAML warnings and use self.registry in reconciliation - Emit PyYAML-missing warning once per function call in bash/PS1 instead of per-preset to avoid spamming stderr - Use self.registry.list_by_priority() in reconciliation methods instead of constructing new PresetRegistry instances to avoid redundant I/O and potential consistency issues * fix: document strategy handling consistency between layers and registrar Composed output already strips strategy from frontmatter (resolve_content pops it). Raw file registration preserves legacy frontmatter strategy for backward compat; reconciliation corrects the final state. * fix: correct stale comments for alias tracking and base-finding algorithm * security: validate manifest file paths in bash/PowerShell resolvers Reject absolute paths and parent directory traversal (..) in the manifest-declared file field before joining with the preset directory. Matches the Python-side validation in PresetManifest._validate(). --------- Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> --- presets/ARCHITECTURE.md | 18 + presets/README.md | 44 +- presets/scaffold/preset.yml | 29 + scripts/bash/common.sh | 227 +++++- scripts/powershell/common.ps1 | 219 ++++++ src/specify_cli/__init__.py | 58 +- src/specify_cli/agents.py | 45 +- src/specify_cli/presets.py | 1284 ++++++++++++++++++++++++++------- tests/test_presets.py | 1231 ++++++++++++++++--------------- 9 files changed, 2299 insertions(+), 856 deletions(-) diff --git a/presets/ARCHITECTURE.md b/presets/ARCHITECTURE.md index d0e6547816..3a119cbd5f 100644 --- a/presets/ARCHITECTURE.md +++ b/presets/ARCHITECTURE.md @@ -41,6 +41,24 @@ The resolution is implemented three times to ensure consistency: - **Bash**: `resolve_template()` in `scripts/bash/common.sh` - **PowerShell**: `Resolve-Template` in `scripts/powershell/common.ps1` +### Composition Strategies + +Templates, commands, and scripts support a `strategy` field that controls how a preset's content is combined with lower-priority content instead of fully replacing it: + +| Strategy | Description | Templates | Commands | Scripts | +|----------|-------------|-----------|----------|---------| +| `replace` (default) | Fully replaces lower-priority content | ✓ | ✓ | ✓ | +| `prepend` | Places content before lower-priority content (separated by a blank line) | ✓ | ✓ | — | +| `append` | Places content after lower-priority content (separated by a blank line) | ✓ | ✓ | — | +| `wrap` | Content contains `{CORE_TEMPLATE}` (templates/commands) or `$CORE_SCRIPT` (scripts) placeholder replaced with lower-priority content | ✓ | ✓ | ✓ | + +Composition is recursive — multiple composing presets chain. The `PresetResolver.resolve_content()` method walks the full priority stack bottom-up and applies each layer's strategy. + +Content resolution functions for composition: +- **Python**: `PresetResolver.resolve_content()` in `src/specify_cli/presets.py` (templates, commands, and scripts) +- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver) +- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver) + ## Command Registration When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`. diff --git a/presets/README.md b/presets/README.md index 72751b4bfb..7d7b9ae8a2 100644 --- a/presets/README.md +++ b/presets/README.md @@ -61,7 +61,37 @@ specify preset add healthcare-compliance --priority 5 # overrides enterprise-sa specify preset add pm-workflow --priority 1 # overrides everything ``` -Presets **override**, they don't merge. If two presets both provide `spec-template`, the one with the lowest priority number wins entirely. +Presets **override by default**, they don't merge. If two presets both provide `spec-template` with the default `replace` strategy, the one with the lowest priority number wins entirely. However, presets can use **composition strategies** to augment rather than replace content. + +### Composition Strategies + +Presets can declare a `strategy` per template to control how content is combined. The `name` field identifies which template to compose with in the priority stack, while `file` points to the actual content file (which can differ from the convention path `templates/.md`): + +```yaml +provides: + templates: + - type: "template" + name: "spec-template" + file: "templates/spec-addendum.md" + strategy: "append" # adds content after the core template +``` + +| Strategy | Description | +|----------|-------------| +| `replace` (default) | Fully replaces the lower-priority template | +| `prepend` | Places content **before** the resolved lower-priority template, separated by a blank line | +| `append` | Places content **after** the resolved lower-priority template, separated by a blank line | +| `wrap` | Content contains `{CORE_TEMPLATE}` placeholder (or `$CORE_SCRIPT` for scripts) replaced with the lower-priority content | + +**Supported combinations:** + +| Type | `replace` | `prepend` | `append` | `wrap` | +|------|-----------|-----------|----------|--------| +| **template** | ✓ (default) | ✓ | ✓ | ✓ | +| **command** | ✓ (default) | ✓ | ✓ | ✓ | +| **script** | ✓ (default) | — | — | ✓ | + +Multiple composing presets chain recursively. For example, a security preset with `prepend` and a compliance preset with `append` will produce: security header + core content + compliance footer. ## Catalog Management @@ -108,13 +138,5 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset The following enhancements are under consideration for future releases: -- **Composition strategies** — Allow presets to declare a `strategy` per template instead of the default `replace`: - - | Type | `replace` | `prepend` | `append` | `wrap` | - |------|-----------|-----------|----------|--------| - | **template** | ✓ (default) | ✓ | ✓ | ✓ | - | **command** | ✓ (default) | ✓ | ✓ | ✓ | - | **script** | ✓ (default) | — | — | ✓ | - - For artifacts and commands (which are LLM directives), `wrap` injects preset content before and after the core template using a `{CORE_TEMPLATE}` placeholder (implemented). For scripts, `wrap` would run custom logic before/after the core script via a `$CORE_SCRIPT` variable (not yet implemented). -- **Script overrides** — Enable presets to provide alternative versions of core scripts (e.g. `create-new-feature.sh`) for workflow customization. A `strategy: "wrap"` option could allow presets to run custom logic before/after the core script without fully replacing it. +- **Structural merge strategies** — Parsing Markdown sections for per-section granularity (e.g., "replace only ## Security"). +- **Conflict detection** — `specify preset lint` / `specify preset doctor` for detecting composition conflicts. diff --git a/presets/scaffold/preset.yml b/presets/scaffold/preset.yml index 975a92a413..65111ba9f3 100644 --- a/presets/scaffold/preset.yml +++ b/presets/scaffold/preset.yml @@ -32,6 +32,15 @@ provides: templates: # CUSTOMIZE: Define your template overrides # Templates are document scaffolds (spec-template.md, plan-template.md, etc.) + # + # Strategy options (optional, defaults to "replace"): + # replace - Fully replaces the lower-priority template (default) + # prepend - Places this content BEFORE the lower-priority template + # append - Places this content AFTER the lower-priority template + # wrap - Uses {CORE_TEMPLATE} placeholder (templates/commands) or + # $CORE_SCRIPT placeholder (scripts), replaced with lower-priority content + # + # Note: Scripts only support "replace" and "wrap" strategies. - type: "template" name: "spec-template" file: "templates/spec-template.md" @@ -45,6 +54,26 @@ provides: # description: "Custom plan template" # replaces: "plan-template" + # COMPOSITION EXAMPLES: + # The `file` field points to the content file (can differ from the + # convention path `templates/.md`). The `name` field identifies + # which template to compose with in the priority stack. + # + # Append additional sections to an existing template: + # - type: "template" + # name: "spec-template" + # file: "templates/spec-addendum.md" + # description: "Add compliance section to spec template" + # strategy: "append" + # + # Wrap a command with preamble/sign-off: + # - type: "command" + # name: "speckit.specify" + # file: "commands/specify-wrapper.md" + # description: "Wrap specify command with compliance checks" + # strategy: "wrap" + # # In the wrapper file, use {CORE_TEMPLATE} where the original content goes + # OVERRIDE EXTENSION TEMPLATES: # Presets sit above extensions in the resolution stack, so you can # override templates provided by any installed extension. diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index b41d17dec3..cad10bdb39 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -320,8 +320,9 @@ try: with open(os.environ['SPECKIT_REGISTRY']) as f: data = json.load(f) presets = data.get('presets', {}) - for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)): - print(pid) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) except Exception: sys.exit(1) " 2>/dev/null); then @@ -373,3 +374,225 @@ except Exception: return 1 } +# Resolve a template name to composed content using composition strategies. +# Reads strategy metadata from preset manifests and composes content +# from multiple layers using prepend, append, or wrap strategies. +# +# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT") +# Returns composed content string on stdout; exit code 1 if not found. +resolve_template_content() { + local template_name="$1" + local repo_root="$2" + local base="$repo_root/.specify/templates" + + # Collect all layers (highest priority first) + local -a layer_paths=() + local -a layer_strategies=() + + # Priority 1: Project overrides (always "replace") + local override="$base/overrides/${template_name}.md" + if [ -f "$override" ]; then + layer_paths+=("$override") + layer_strategies+=("replace") + fi + + # Priority 2: Installed presets (sorted by priority from .registry) + local presets_dir="$repo_root/.specify/presets" + if [ -d "$presets_dir" ]; then + local registry_file="$presets_dir/.registry" + local sorted_presets="" + if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then + if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c " +import json, sys, os +try: + with open(os.environ['SPECKIT_REGISTRY']) as f: + data = json.load(f) + presets = data.get('presets', {}) + for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10): + if isinstance(meta, dict) and meta.get('enabled', True) is not False: + print(pid) +except Exception: + sys.exit(1) +" 2>/dev/null); then + if [ -n "$sorted_presets" ]; then + local yaml_warned=false + while IFS= read -r preset_id; do + # Read strategy and file path from preset manifest + local strategy="replace" + local manifest_file="" + local manifest="$presets_dir/$preset_id/preset.yml" + if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then + # Requires PyYAML; falls back to replace/convention if unavailable + local result + local py_stderr + py_stderr=$(mktemp) + result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c " +import sys, os +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(os.environ['SPECKIT_MANIFEST']) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template': + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + sys.exit(0) + print('replace\t') +except Exception: + print('replace\t') +" 2>"$py_stderr") + local parse_status=$? + if [ $parse_status -eq 0 ] && [ -n "$result" ]; then + IFS=$'\t' read -r strategy manifest_file <<< "$result" + strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]') + fi + if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then + echo "Warning: PyYAML not available; composition strategies may be ignored" >&2 + yaml_warned=true + fi + rm -f "$py_stderr" + fi + # Try manifest file path first, then convention path + local candidate="" + if [ -n "$manifest_file" ]; then + # Reject absolute paths and parent traversal + case "$manifest_file" in + /*|*../*|../*) manifest_file="" ;; + esac + fi + if [ -n "$manifest_file" ]; then + local mf="$presets_dir/$preset_id/$manifest_file" + [ -f "$mf" ] && candidate="$mf" + fi + if [ -z "$candidate" ]; then + local cf="$presets_dir/$preset_id/templates/${template_name}.md" + [ -f "$cf" ] && candidate="$cf" + fi + if [ -n "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("$strategy") + fi + done <<< "$sorted_presets" + fi + else + # python3 failed — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + else + # No python3 or registry — fall back to unordered directory scan (replace only) + for preset in "$presets_dir"/*/; do + [ -d "$preset" ] || continue + local candidate="$preset/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + fi + + # Priority 3: Extension-provided templates (always "replace") + local ext_dir="$repo_root/.specify/extensions" + if [ -d "$ext_dir" ]; then + for ext in "$ext_dir"/*/; do + [ -d "$ext" ] || continue + case "$(basename "$ext")" in .*) continue;; esac + local candidate="$ext/templates/${template_name}.md" + if [ -f "$candidate" ]; then + layer_paths+=("$candidate") + layer_strategies+=("replace") + fi + done + fi + + # Priority 4: Core templates (always "replace") + local core="$base/${template_name}.md" + if [ -f "$core" ]; then + layer_paths+=("$core") + layer_strategies+=("replace") + fi + + local count=${#layer_paths[@]} + [ "$count" -eq 0 ] && return 1 + + # Check if any layer uses a non-replace strategy + local has_composition=false + for s in "${layer_strategies[@]}"; do + [ "$s" != "replace" ] && has_composition=true && break + done + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if [ "${layer_strategies[0]}" = "replace" ]; then + cat "${layer_paths[0]}" + return 0 + fi + + if [ "$has_composition" = false ]; then + cat "${layer_paths[0]}" + return 0 + fi + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + local base_idx=-1 + local i + for (( i=0; i=0; i-- )); do + local path="${layer_paths[$i]}" + local strat="${layer_strategies[$i]}" + local layer_content + # Preserve trailing newlines + layer_content=$(cat "$path"; printf x) + layer_content="${layer_content%x}" + + case "$strat" in + replace) content="$layer_content" ;; + prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;; + append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;; + wrap) + case "$layer_content" in + *'{CORE_TEMPLATE}'*) ;; + *) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;; + esac + while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do + local before="${layer_content%%\{CORE_TEMPLATE\}*}" + local after="${layer_content#*\{CORE_TEMPLATE\}}" + layer_content="${before}${content}${after}" + done + content="$layer_content" + ;; + *) echo "Error: unknown strategy '$strat'" >&2; return 1 ;; + esac + done + + printf '%s' "$content" + return 0 +} + diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 0d6544aaf4..d799e4f7e7 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -287,6 +287,21 @@ function Test-DirHasFiles { } } +# Find a usable Python 3 executable (python3, python, or py -3). +# Returns the command/arguments as an array, or $null if none found. +function Get-Python3Command { + if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') } + if (Get-Command python -ErrorAction SilentlyContinue) { + $ver = & python --version 2>&1 + if ($ver -match 'Python 3') { return @('python') } + } + if (Get-Command py -ErrorAction SilentlyContinue) { + $ver = & py -3 --version 2>&1 + if ($ver -match 'Python 3') { return @('py', '-3') } + } + return $null +} + # Resolve a template name to a file path using the priority stack: # 1. .specify/templates/overrides/ # 2. .specify/presets//templates/ (sorted by priority from .registry) @@ -315,6 +330,7 @@ function Resolve-Template { $presets = $registryData.presets if ($presets) { $sortedPresets = $presets.PSObject.Properties | + Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } | Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | ForEach-Object { $_.Name } } @@ -354,3 +370,206 @@ function Resolve-Template { return $null } +# Resolve a template name to composed content using composition strategies. +# Reads strategy metadata from preset manifests and composes content +# from multiple layers using prepend, append, or wrap strategies. +function Resolve-TemplateContent { + param( + [Parameter(Mandatory=$true)][string]$TemplateName, + [Parameter(Mandatory=$true)][string]$RepoRoot + ) + + $base = Join-Path $RepoRoot '.specify/templates' + + # Collect all layers (highest priority first) + $layerPaths = @() + $layerStrategies = @() + + # Priority 1: Project overrides (always "replace") + $override = Join-Path $base "overrides/$TemplateName.md" + if (Test-Path $override) { + $layerPaths += $override + $layerStrategies += 'replace' + } + + # Priority 2: Installed presets (sorted by priority from .registry) + $presetsDir = Join-Path $RepoRoot '.specify/presets' + if (Test-Path $presetsDir) { + $registryFile = Join-Path $presetsDir '.registry' + $sortedPresets = @() + if (Test-Path $registryFile) { + try { + $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json + $presets = $registryData.presets + if ($presets) { + $sortedPresets = $presets.PSObject.Properties | + Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } | + Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } | + ForEach-Object { $_.Name } + } + } catch { + $sortedPresets = @() + } + } + + if ($sortedPresets.Count -gt 0) { + $pyCmd = Get-Python3Command + if (-not $pyCmd) { + # Check if any preset has strategy fields that would be ignored + foreach ($pid in $sortedPresets) { + $mf = Join-Path $presetsDir "$pid/preset.yml" + if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) { + Write-Warning "No Python 3 found; preset composition strategies will be ignored" + break + } + } + } + $yamlWarned = $false + foreach ($presetId in $sortedPresets) { + # Read strategy and file path from preset manifest + $strategy = 'replace' + $manifestFilePath = '' + $manifest = Join-Path $presetsDir "$presetId/preset.yml" + if ((Test-Path $manifest) -and $pyCmd) { + try { + # Use Python to parse YAML manifest for strategy and file path + $pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() } + $pyStderrFile = [System.IO.Path]::GetTempFileName() + $stratResult = & $pyCmd[0] @pyArgs -c @" +import sys +try: + import yaml +except ImportError: + print('yaml_missing', file=sys.stderr) + print('replace\t') + sys.exit(0) +try: + with open(sys.argv[1]) as f: + data = yaml.safe_load(f) + for t in data.get('provides', {}).get('templates', []): + if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template': + print(t.get('strategy', 'replace') + '\t' + t.get('file', '')) + sys.exit(0) + print('replace\t') +except Exception: + print('replace\t') +"@ $manifest $TemplateName 2>$pyStderrFile + if ($stratResult) { + $parts = $stratResult.Trim() -split "`t", 2 + $strategy = $parts[0].ToLowerInvariant() + if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] } + } + if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') { + Write-Warning "PyYAML not available; composition strategies may be ignored" + $yamlWarned = $true + } + Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue + } catch { + $strategy = 'replace' + if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue } + } + } + # Try manifest file path first, then convention path + $candidate = $null + if ($manifestFilePath) { + # Reject absolute paths and parent traversal + if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') { + $manifestFilePath = '' + } + } + if ($manifestFilePath) { + $mf = Join-Path $presetsDir "$presetId/$manifestFilePath" + if (Test-Path $mf) { $candidate = $mf } + } + if (-not $candidate) { + $cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md" + if (Test-Path $cf) { $candidate = $cf } + } + if ($candidate) { + $layerPaths += $candidate + $layerStrategies += $strategy + } + } + } else { + # Fallback: alphabetical directory order (no registry or parse failure) + foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) { + $candidate = Join-Path $preset.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { + $layerPaths += $candidate + $layerStrategies += 'replace' + } + } + } + } + + # Priority 3: Extension-provided templates (always "replace") + $extDir = Join-Path $RepoRoot '.specify/extensions' + if (Test-Path $extDir) { + foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) { + $candidate = Join-Path $ext.FullName "templates/$TemplateName.md" + if (Test-Path $candidate) { + $layerPaths += $candidate + $layerStrategies += 'replace' + } + } + } + + # Priority 4: Core templates (always "replace") + $core = Join-Path $base "$TemplateName.md" + if (Test-Path $core) { + $layerPaths += $core + $layerStrategies += 'replace' + } + + if ($layerPaths.Count -eq 0) { return $null } + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if ($layerStrategies[0] -eq 'replace') { + return (Get-Content $layerPaths[0] -Raw) + } + + # Check if any layer uses a non-replace strategy + $hasComposition = $false + foreach ($s in $layerStrategies) { + if ($s -ne 'replace') { $hasComposition = $true; break } + } + + if (-not $hasComposition) { + return (Get-Content $layerPaths[0] -Raw) + } + + # Find the effective base: scan from highest priority (index 0) downward + # to find the nearest replace layer. Only compose layers above that base. + $baseIdx = -1 + for ($i = 0; $i -lt $layerPaths.Count; $i++) { + if ($layerStrategies[$i] -eq 'replace') { + $baseIdx = $i + break + } + } + if ($baseIdx -lt 0) { return $null } + + $content = Get-Content $layerPaths[$baseIdx] -Raw + + for ($i = $baseIdx - 1; $i -ge 0; $i--) { + $path = $layerPaths[$i] + $strat = $layerStrategies[$i] + $layerContent = Get-Content $path -Raw + + switch ($strat) { + 'replace' { $content = $layerContent } + 'prepend' { $content = "$layerContent`n`n$content" } + 'append' { $content = "$content`n`n$layerContent" } + 'wrap' { + if (-not $layerContent.Contains('{CORE_TEMPLATE}')) { + throw "Wrap strategy missing {CORE_TEMPLATE} placeholder" + } + $content = $layerContent.Replace('{CORE_TEMPLATE}', $content) + } + default { throw "Unknown strategy: $strat" } + } + } + + return $content +} \ No newline at end of file diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 743ceb9954..77611128b5 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2762,14 +2762,58 @@ def preset_resolve( raise typer.Exit(1) resolver = PresetResolver(project_root) - result = resolver.resolve_with_source(template_name) - - if result: - console.print(f" [bold]{template_name}[/bold]: {result['path']}") - console.print(f" [dim](from: {result['source']})[/dim]") + layers = resolver.collect_all_layers(template_name) + + if layers: + # Use the highest-priority layer for display because the final output + # may be composed and may not map to resolve_with_source()'s single path. + display_layer = layers[0] + console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}") + console.print(f" [dim](top layer from: {display_layer['source']})[/dim]") + + has_composition = ( + layers[0]["strategy"] != "replace" + and any(layer["strategy"] != "replace" for layer in layers) + ) + if has_composition: + # Verify composition is actually possible + try: + composed = resolver.resolve_content(template_name) + except Exception as exc: + composed = None + console.print(f" [yellow]Warning: composition error: {exc}[/yellow]") + if composed is None: + console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]") + else: + console.print(" [dim]Final output is composed from multiple preset layers; the path above is the highest-priority contributing layer.[/dim]") + console.print("\n [bold]Composition chain:[/bold]") + # Compute the effective base: first replace layer scanning from + # highest priority (matching resolve_content top-down logic). + # Only show layers from the base upward (lower layers are ignored). + effective_base_idx = None + for idx, lyr in enumerate(layers): + if lyr["strategy"] == "replace": + effective_base_idx = idx + break + # Show only contributing layers (base and above) + if effective_base_idx is not None: + contributing = layers[:effective_base_idx + 1] + else: + contributing = layers + for i, layer in enumerate(reversed(contributing)): + strategy_label = layer["strategy"] + if strategy_label == "replace" and i == 0: + strategy_label = "base" + console.print(f" {i + 1}. [{strategy_label}] {layer['source']} → {layer['path']}") else: - console.print(f" [yellow]{template_name}[/yellow]: not found") - console.print(" [dim]No template with this name exists in the resolution stack[/dim]") + # No layers found — fall back to resolve_with_source for non-composition cases + result = resolver.resolve_with_source(template_name) + if result: + console.print(f" [bold]{template_name}[/bold]: {result['path']}") + console.print(f" [dim](from: {result['source']})[/dim]") + else: + console.print(f" [yellow]{template_name}[/yellow]: not found") + console.print(" [dim]No template with this name exists in the resolution stack[/dim]") @preset_app.command("info") diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 43cbfbe08c..726b0fd2a6 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -8,7 +8,7 @@ import os from pathlib import Path -from typing import Dict, List, Any +from typing import Dict, List, Any, Optional import platform import re @@ -652,6 +652,49 @@ def register_commands_for_all_agents( return results + def register_commands_for_non_skill_agents( + self, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + project_root: Path, + context_note: Optional[str] = None, + ) -> Dict[str, List[str]]: + """Register commands for all non-skill agents in the project. + + Like register_commands_for_all_agents but skips skill-based agents + (those with extension '/SKILL.md'). Used by reconciliation to avoid + overwriting properly formatted SKILL.md files. + + Args: + commands: List of command info dicts + source_id: Identifier of the source + source_dir: Directory containing command source files + project_root: Path to project root + context_note: Custom context comment for markdown output + + Returns: + Dictionary mapping agent names to list of registered commands + """ + results = {} + self._ensure_configs() + for agent_name, agent_config in self.AGENT_CONFIGS.items(): + if agent_config.get("extension") == "/SKILL.md": + continue + agent_dir = project_root / agent_config["dir"] + if agent_dir.exists(): + try: + registered = self.register_commands( + agent_name, commands, source_id, + source_dir, project_root, + context_note=context_note, + ) + if registered: + results[agent_name] = registered + except ValueError: + continue + return results + def unregister_commands( self, registered_commands: Dict[str, List[str]], project_root: Path ) -> None: diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 5f28be7204..ed33f992c3 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -109,6 +109,9 @@ class PresetCompatibilityError(PresetError): VALID_PRESET_TEMPLATE_TYPES = {"template", "command", "script"} +VALID_PRESET_STRATEGIES = {"replace", "prepend", "append", "wrap"} +# Scripts only support replace and wrap (prepend/append don't make semantic sense for executable code) +VALID_SCRIPT_STRATEGIES = {"replace", "wrap"} class PresetManifest: @@ -207,6 +210,28 @@ def _validate(self): "must be a relative path within the preset directory" ) + # Validate strategy field (optional, defaults to "replace") + strategy = tmpl.get("strategy", "replace") + if not isinstance(strategy, str): + raise PresetValidationError( + f"Invalid strategy value: must be a string, " + f"got {type(strategy).__name__}" + ) + strategy = strategy.lower() + # Persist normalized value so downstream code sees lowercase + if "strategy" in tmpl: + tmpl["strategy"] = strategy + if strategy not in VALID_PRESET_STRATEGIES: + raise PresetValidationError( + f"Invalid strategy '{strategy}': " + f"must be one of {sorted(VALID_PRESET_STRATEGIES)}" + ) + if tmpl["type"] == "script" and strategy not in VALID_SCRIPT_STRATEGIES: + raise PresetValidationError( + f"Invalid strategy '{strategy}' for script: " + f"scripts only support {sorted(VALID_SCRIPT_STRATEGIES)}" + ) + # Validate template name format if tmpl["type"] == "command": # Commands use dot notation (e.g. speckit.specify) @@ -558,6 +583,10 @@ def _register_commands( file, and writes it to every detected agent directory using the CommandRegistrar from the agents module. + When a command uses a composition strategy (prepend, append, wrap), + the content is composed with the lower-priority command before + registration. + Args: manifest: Preset manifest preset_dir: Installed preset directory @@ -587,6 +616,50 @@ def _register_commands( if not filtered: return {} + # Handle composition strategies: resolve composed content for non-replace commands + resolver = PresetResolver(self.project_root) + composed_dir = None + commands_to_register = [] + for cmd in filtered: + strategy = cmd.get("strategy", "replace") + if strategy != "replace": + # Only pre-compose if this preset is the top composing layer. + # If a higher-priority replace already wins, skip composition + # here — reconciliation will write the correct content. + layers = resolver.collect_all_layers(cmd["name"], "command") + top_layer_is_ours = ( + layers and layers[0]["path"].is_relative_to(preset_dir) + ) + if top_layer_is_ours: + composed = resolver.resolve_content(cmd["name"], "command") + if composed is not None: + if composed_dir is None: + composed_dir = preset_dir / ".composed" + composed_dir.mkdir(parents=True, exist_ok=True) + composed_file = composed_dir / f"{cmd['name']}.md" + composed_file.write_text(composed, encoding="utf-8") + commands_to_register.append({ + **cmd, + "file": f".composed/{cmd['name']}.md", + }) + else: + raise PresetValidationError( + f"Command '{cmd['name']}' uses '{strategy}' strategy " + f"but no base command layer exists to compose onto. " + f"Ensure a lower-priority preset, extension, or core " + f"command provides this command before using " + f"composition strategies." + ) + else: + # Not the top layer — register raw file; reconciliation + # will overwrite with the correct composed/winning content. + # Note: CommandRegistrar may process frontmatter strategy: wrap + # from the raw file (legacy compat), but reconciliation runs + # immediately after install and corrects the final output. + commands_to_register.append(cmd) + else: + commands_to_register.append(cmd) + try: from .agents import CommandRegistrar except ImportError: @@ -594,7 +667,7 @@ def _register_commands( registrar = CommandRegistrar() return registrar.register_commands_for_all_agents( - filtered, manifest.id, preset_dir, self.project_root + commands_to_register, manifest.id, preset_dir, self.project_root ) def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None: @@ -611,231 +684,402 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non registrar = CommandRegistrar() registrar.unregister_commands(registered_commands, self.project_root) - def _replay_wraps_for_command(self, cmd_name: str) -> None: - """Recompose and rewrite agent files for a wrap-strategy command. - - Collects all installed presets that declare cmd_name in their - wrap_commands registry field, sorts them so the highest-precedence - preset (lowest priority number) wraps outermost, then writes the - fully composed output to every agent directory. + def _reconcile_composed_commands(self, command_names: List[str]) -> None: + """Re-resolve and re-register composed commands from the full stack. - Called after every install and remove to keep agent files correct - regardless of installation order. + After install or remove, recompute the effective content for each + command name that participates in composition, and write the winning + content to the agent directories. This ensures command files always + reflect the current priority stack rather than depending on + install/remove order. Args: - cmd_name: Full command name (e.g. "speckit.specify") + command_names: List of command names to reconcile """ + if not command_names: + return + try: from .agents import CommandRegistrar except ImportError: return - # Collect enabled presets that wrap this command, sorted ascending - # (lowest priority number = highest precedence = outermost). - wrap_presets = [] - for pack_id, metadata in self.registry.list_by_priority(include_disabled=False): - if cmd_name not in metadata.get("wrap_commands", []): - continue - pack_dir = self.presets_dir / pack_id - if not pack_dir.is_dir(): - continue # corrupted state — skip - wrap_presets.append((pack_id, pack_dir)) + resolver = PresetResolver(self.project_root) + registrar = CommandRegistrar() - if not wrap_presets: - return + # Cache registry and manifests outside the loop to avoid + # repeated filesystem reads for each command name. + presets_by_priority = list(self.registry.list_by_priority()) - # Derive short name for core resolution fallback. - short_name = cmd_name - if short_name.startswith("speckit."): - short_name = short_name[len("speckit."):] + for cmd_name in command_names: + layers = resolver.collect_all_layers(cmd_name, "command") + if not layers: + continue - resolver = PresetResolver(self.project_root) - core_file = ( - resolver.resolve_core(cmd_name, "command") - or resolver.resolve_extension_command_via_manifest(cmd_name) - or ( - resolver.resolve_extension_command_via_manifest(short_name) - if short_name != cmd_name - else None + # If the top layer is replace, it wins entirely — lower layers + # are irrelevant regardless of their strategies. + top_is_replace = layers[0]["strategy"] == "replace" + has_composition = not top_is_replace and any( + layer["strategy"] != "replace" for layer in layers ) - or resolver.resolve_core(short_name, "command") - ) - if core_file is None: - return - - registrar = CommandRegistrar() - core_frontmatter, core_body = registrar.parse_frontmatter( - core_file.read_text(encoding="utf-8") - ) - replay_aliases: List[str] = [] - seen_aliases: set[str] = set() - - # Apply wraps innermost-first (reverse of ascending list). - accumulated_body = core_body - outermost_frontmatter = {} - outermost_pack_id = wrap_presets[0][0] # fallback; updated per contributing preset - for pack_id, pack_dir in reversed(wrap_presets): - manifest_path = pack_dir / "preset.yml" - cmd_file: Optional[Path] = None - if manifest_path.exists(): - try: - manifest = PresetManifest(manifest_path) - except (PresetValidationError, KeyError, TypeError, ValueError): - manifest = None - if manifest is not None: - for template in manifest.templates: - if template.get("type") != "command" or template.get("name") != cmd_name: - continue - file_rel = template.get("file") - if isinstance(file_rel, str): - rel_path = Path(file_rel) - if not rel_path.is_absolute(): - try: - preset_root = pack_dir.resolve() - candidate = (preset_root / rel_path).resolve() - candidate.relative_to(preset_root) - except (OSError, ValueError): - candidate = None - if candidate is not None: - cmd_file = candidate - aliases = template.get("aliases", []) - if not isinstance(aliases, list): - aliases = [] - for alias in aliases: - if isinstance(alias, str) and alias not in seen_aliases: - replay_aliases.append(alias) - seen_aliases.add(alias) + if not has_composition: + # Pure replace — the top layer wins. + top_layer = layers[0] + top_path = top_layer["path"] + # Try to find which preset owns this layer + registered = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + if top_path.is_relative_to(pack_dir): + manifest = resolver._get_manifest(pack_dir) + if manifest: + for tmpl in manifest.templates: + if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": + self._register_for_non_skill_agents( + registrar, [tmpl], manifest.id, pack_dir + ) + registered = True + break break - if cmd_file is None: - cmd_file = pack_dir / "commands" / f"{cmd_name}.md" - if not cmd_file.exists(): - continue - wrap_fm, wrap_body = registrar.parse_frontmatter( - cmd_file.read_text(encoding="utf-8") - ) - accumulated_body = wrap_body.replace("{CORE_TEMPLATE}", accumulated_body) - outermost_frontmatter = wrap_fm # last iteration = outermost preset - outermost_pack_id = pack_id - - # Build final frontmatter: outermost preset wins; fall back to core for - # scripts/agent_scripts if the outermost preset does not define them. - final_frontmatter = dict(outermost_frontmatter) - final_frontmatter.pop("strategy", None) - for key in ("scripts", "agent_scripts"): - if key not in final_frontmatter and key in core_frontmatter: - final_frontmatter[key] = core_frontmatter[key] - - composed_content = ( - registrar.render_frontmatter(final_frontmatter) + "\n" + accumulated_body - ) - - self._replay_skill_override(cmd_name, composed_content, outermost_pack_id) - - with tempfile.TemporaryDirectory() as tmpdir: - tmp_path = Path(tmpdir) - cmd_dir = tmp_path / "commands" - cmd_dir.mkdir() - (cmd_dir / f"{cmd_name}.md").write_text(composed_content, encoding="utf-8") - registrar._ensure_configs() - for agent_name, agent_config in registrar.AGENT_CONFIGS.items(): - if agent_config.get("extension") == "/SKILL.md": - continue - agent_dir = self.project_root / agent_config["dir"] - if not agent_dir.exists(): - continue - try: - registrar.register_commands( - agent_name, - [{ - "name": cmd_name, - "file": f"commands/{cmd_name}.md", - "aliases": replay_aliases, - }], - f"preset:{outermost_pack_id}", - tmp_path, + if not registered: + # Top layer is a non-preset source (extension, core, or + # project override). Register directly from the layer path. + source = layers[0]["source"] + if source.startswith("extension:"): + # Use extension's own registration to preserve context formatting + ext_id = source.split(":", 1)[1].split(" ", 1)[0] + ext_dir = self.project_root / ".specify" / "extensions" / ext_id + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.exists(): + try: + from .extensions import ExtensionManifest + ext_manifest = ExtensionManifest(ext_manifest_path) + # Filter to only the command being reconciled + matching_cmds = [ + c for c in ext_manifest.commands + if c.get("name") == cmd_name + ] + if matching_cmds: + registrar.register_commands_for_non_skill_agents( + matching_cmds, ext_id, ext_dir, + self.project_root, + context_note=f"\n\n\n", + ) + registered = True + except Exception: + # Extension registration failed; fall back to + # generic path-based registration below. + pass + if not registered: + source_id = source.split(":", 1)[1].split(" ", 1)[0] if source.startswith("extension:") else source + self._register_command_from_path( + registrar, cmd_name, top_path, + source_id=source_id, + ) + else: + # Composed command — resolve from full stack + composed = resolver.resolve_content(cmd_name, "command") + if composed is None: + # Composition no longer possible (e.g. base layer removed). + # Unregister any stale command file from non-skill agents. + import warnings + warnings.warn( + f"Cannot compose command '{cmd_name}': no base layer. " + f"Stale command files may remain.", + stacklevel=2, + ) + registrar._ensure_configs() + # Include aliases from the top layer's manifest + cmd_names_to_unregister = [cmd_name] + for _pid, _meta in presets_by_priority: + _pd = self.presets_dir / _pid + _m = resolver._get_manifest(_pd) + if _m: + for _t in _m.templates: + if _t.get("name") == cmd_name and _t.get("type") == "command": + for alias in _t.get("aliases", []): + if isinstance(alias, str): + cmd_names_to_unregister.append(alias) + break + registrar.unregister_commands( + {agent: cmd_names_to_unregister for agent in registrar.AGENT_CONFIGS + if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"}, self.project_root, ) - except ValueError: continue - def _replay_skill_override( + # Write to the highest-priority preset's .composed dir + registered = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + manifest = resolver._get_manifest(pack_dir) + if not manifest: + continue + for tmpl in manifest.templates: + if tmpl.get("name") == cmd_name and tmpl.get("type") == "command": + composed_dir = pack_dir / ".composed" + composed_dir.mkdir(parents=True, exist_ok=True) + composed_file = composed_dir / f"{cmd_name}.md" + composed_file.write_text(composed, encoding="utf-8") + self._register_for_non_skill_agents( + registrar, + [{**tmpl, "file": f".composed/{cmd_name}.md"}], + manifest.id, pack_dir, + ) + registered = True + break + else: + continue + break + if not registered: + # No preset owns this composed command — write to a + # shared .composed dir and register from the top layer. + shared_composed = self.presets_dir / ".composed" + shared_composed.mkdir(parents=True, exist_ok=True) + composed_file = shared_composed / f"{cmd_name}.md" + composed_file.write_text(composed, encoding="utf-8") + source = layers[0]["source"] + if source.startswith("extension:"): + source_id = source.split(":", 1)[1].split(" ", 1)[0] + else: + source_id = source + self._register_command_from_path( + registrar, cmd_name, composed_file, + source_id=source_id, + ) + + def _register_command_from_path( self, + registrar: Any, cmd_name: str, - composed_content: str, - outermost_pack_id: str, + cmd_path: Path, + source_id: str = "reconciled", ) -> None: - """Rewrite any active SKILL.md override for a replayed wrap command.""" - skills_dir = self._get_skills_dir() - if not skills_dir: - return + """Register a single command from a file path (non-preset source). - from . import SKILL_DESCRIPTIONS, load_init_options - from .agents import CommandRegistrar - from .integrations import get_integration + Used by reconciliation when the winning layer is an extension, + core template, or project override rather than a preset. - init_opts = load_init_options(self.project_root) - if not isinstance(init_opts, dict): - init_opts = {} - selected_ai = init_opts.get("ai") - if not isinstance(selected_ai, str): + Args: + registrar: CommandRegistrar instance + cmd_name: Command name + cmd_path: Path to the command file + source_id: Source attribution for rendered output + """ + if not cmd_path.exists(): return + cmd_tmpl: Dict[str, Any] = { + "name": cmd_name, + "type": "command", + "file": cmd_path.name, + } + # Load aliases from extension manifest when the winning layer is an extension + if source_id and not source_id.startswith("preset:"): + try: + from .extensions import ExtensionManifest + for ext_dir in (self.project_root / ".specify" / "extensions").iterdir(): + if not ext_dir.is_dir(): + continue + if cmd_path.is_relative_to(ext_dir): + manifest_path = ext_dir / "extension.yml" + if manifest_path.exists(): + ext_manifest = ExtensionManifest(manifest_path) + for cmd in ext_manifest.commands: + if cmd.get("name") == cmd_name: + aliases = cmd.get("aliases", []) + if isinstance(aliases, list) and aliases: + cmd_tmpl["aliases"] = aliases + break + break + except Exception: + pass # best-effort alias loading + self._register_for_non_skill_agents( + registrar, [cmd_tmpl], source_id, cmd_path.parent + ) - registrar = CommandRegistrar() - integration = get_integration(selected_ai) - agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {}) - create_missing_skills = bool(init_opts.get("ai_skills")) and agent_config.get("extension") != "/SKILL.md" - - skill_name, legacy_skill_name = self._skill_names_for_command(cmd_name) - target_skill_names: List[str] = [] - if (skills_dir / skill_name).is_dir(): - target_skill_names.append(skill_name) - if legacy_skill_name != skill_name and (skills_dir / legacy_skill_name).is_dir(): - target_skill_names.append(legacy_skill_name) - if not target_skill_names and create_missing_skills: - missing_skill_dir = skills_dir / skill_name - if not missing_skill_dir.exists(): - target_skill_names.append(skill_name) - if not target_skill_names: + def _register_for_non_skill_agents( + self, + registrar: Any, + commands: List[Dict[str, Any]], + source_id: str, + source_dir: Path, + ) -> None: + """Register commands for non-skill agents during reconciliation. + + Skill-based agents (``/SKILL.md`` layout) are handled separately: + - On removal: ``_unregister_skills()`` restores from core/extension, + then ``_reconcile_skills()`` re-runs ``_register_skills()`` for the + next winning preset so SKILL.md files get proper frontmatter and + descriptions. + - On install: ``_register_skills()`` writes formatted SKILL.md, then + ``_reconcile_skills()`` ensures the actual priority winner is used. + + Writing raw command content to skill agents would produce invalid + SKILL.md files (missing skill frontmatter, descriptions, etc.). + """ + registrar.register_commands_for_non_skill_agents( + commands, source_id, source_dir, self.project_root + ) + + class _FilteredManifest: + """Wrapper that exposes only selected command templates from a manifest. + + Used by _reconcile_skills to avoid overwriting skills for commands + that aren't being reconciled. + """ + + def __init__(self, manifest: "PresetManifest", cmd_names: set): + self._manifest = manifest + self._cmd_names = cmd_names + + def __getattr__(self, name: str): + return getattr(self._manifest, name) + + @property + def templates(self) -> List[Dict[str, Any]]: + return [ + t for t in self._manifest.templates + if t.get("name") in self._cmd_names + ] + + def _reconcile_skills(self, command_names: List[str]) -> None: + """Re-register skills for commands whose winning layer changed. + + After a preset is removed, finds the next preset in the priority + stack that provides each command and re-runs skill registration + for that preset so SKILL.md files reflect the current winner. + + Args: + command_names: List of command names to reconcile skills for + """ + if not command_names: return - raw_short_name = cmd_name - if raw_short_name.startswith("speckit."): - raw_short_name = raw_short_name[len("speckit."):] - short_name = raw_short_name.replace(".", "-") - skill_title = self._skill_title_from_command(cmd_name) - - frontmatter, body = registrar.parse_frontmatter(composed_content) - original_desc = frontmatter.get("description", "") - enhanced_desc = SKILL_DESCRIPTIONS.get( - short_name, - original_desc or f"Spec-kit workflow command: {short_name}", - ) - body = registrar.resolve_skill_placeholders( - selected_ai, dict(frontmatter), body, self.project_root - ) + resolver = PresetResolver(self.project_root) + skills_dir = self._get_skills_dir() - for target_skill_name in target_skill_names: - skill_subdir = skills_dir / target_skill_name - if skill_subdir.exists() and not skill_subdir.is_dir(): + # Cache registry once to avoid repeated filesystem reads + presets_by_priority = list(self.registry.list_by_priority()) + + # Group command names by winning preset to batch _register_skills calls + # while only registering skills for the specific commands being reconciled. + preset_cmds: Dict[str, List[str]] = {} + non_preset_skills: List[tuple] = [] + + for cmd_name in command_names: + layers = resolver.collect_all_layers(cmd_name, "command") + if not layers: continue - skill_subdir.mkdir(parents=True, exist_ok=True) - frontmatter_data = registrar.build_skill_frontmatter( - selected_ai, - target_skill_name, - enhanced_desc, - f"preset:{outermost_pack_id}", - ) - frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip() - skill_content = ( - f"---\n" - f"{frontmatter_text}\n" - f"---\n\n" - f"# Speckit {skill_title} Skill\n\n" - f"{body}\n" - ) - if integration is not None and hasattr(integration, "post_process_skill_content"): - skill_content = integration.post_process_skill_content(skill_content) - (skill_subdir / "SKILL.md").write_text(skill_content, encoding="utf-8") + + # Re-create the skill directory only if it was previously managed + # (i.e., listed in some preset's registered_skills). This avoids + # creating new skill dirs that _register_skills would normally skip. + if skills_dir: + skill_name, _ = self._skill_names_for_command(cmd_name) + skill_subdir = skills_dir / skill_name + if not skill_subdir.exists(): + # Check if any preset previously registered this skill + was_managed = False + for _pid, meta in presets_by_priority: + if not isinstance(meta, dict): + continue + if skill_name in meta.get("registered_skills", []): + was_managed = True + break + if was_managed: + skill_subdir.mkdir(parents=True, exist_ok=True) + + top_path = layers[0]["path"] + # Find the preset that owns the winning layer + found_preset = False + for pack_id, _meta in presets_by_priority: + pack_dir = self.presets_dir / pack_id + if top_path.is_relative_to(pack_dir): + preset_cmds.setdefault(pack_id, []).append(cmd_name) + found_preset = True + break + if not found_preset: + # Winner is a non-preset source (core/extension/override). + # Track the winning layer path for skill restoration. + skill_name, _ = self._skill_names_for_command(cmd_name) + non_preset_skills.append((skill_name, cmd_name, layers[0])) + + # Restore skills for commands whose winner is non-preset. + if non_preset_skills and skills_dir: + # Separate override-backed skills from core/extension-backed ones. + # _unregister_skills can rmtree the skill dir, so overrides must + # be handled directly (create dir + write) without that call. + core_ext_skills = [] + override_skills = [] + for item in non_preset_skills: + if item[2]["source"] == "project override": + override_skills.append(item) + else: + core_ext_skills.append(item) + + if core_ext_skills: + self._unregister_skills( + [s[0] for s in core_ext_skills], self.presets_dir + ) + + for skill_name, cmd_name, top_layer in override_skills: + skill_subdir = skills_dir / skill_name + skill_subdir.mkdir(parents=True, exist_ok=True) + skill_file = skill_subdir / "SKILL.md" + try: + from .agents import CommandRegistrar + from . import SKILL_DESCRIPTIONS, load_init_options + registrar = CommandRegistrar() + content = top_layer["path"].read_text(encoding="utf-8") + fm, body = registrar.parse_frontmatter(content) + short_name = cmd_name + if short_name.startswith("speckit."): + short_name = short_name[len("speckit."):] + desc = SKILL_DESCRIPTIONS.get( + short_name.replace(".", "-"), + fm.get("description", f"Command: {short_name}"), + ) + init_opts = load_init_options(self.project_root) + selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else "" + if isinstance(selected_ai, str): + body = registrar.resolve_skill_placeholders( + selected_ai, fm, body, self.project_root + ) + fm_data = registrar.build_skill_frontmatter( + selected_ai if isinstance(selected_ai, str) else "", + skill_name, desc, + f"override:{cmd_name}", + ) + fm_text = yaml.safe_dump(fm_data, sort_keys=False).strip() + skill_title = self._skill_title_from_command(cmd_name) + skill_content = ( + f"---\n{fm_text}\n---\n\n" + f"# Speckit {skill_title} Skill\n\n{body}\n" + ) + # Apply integration post-processing (e.g. Claude flags) + from .integrations import get_integration + integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None + if integration is not None and hasattr(integration, "post_process_skill_content"): + skill_content = integration.post_process_skill_content(skill_content) + skill_file.write_text(skill_content, encoding="utf-8") + except Exception: + pass # best-effort override skill restoration + + # Register skills only for the specific commands being reconciled, + # not all commands in each winning preset's manifest. + for pack_id, cmds in preset_cmds.items(): + pack_dir = self.presets_dir / pack_id + manifest_path = pack_dir / "preset.yml" + if not manifest_path.exists(): + continue + try: + manifest = PresetManifest(manifest_path) + except PresetValidationError: + continue + # Filter manifest to only the commands being reconciled + cmds_set = set(cmds) + filtered_manifest = self._FilteredManifest(manifest, cmds_set) + self._register_skills(filtered_manifest, pack_dir) def _get_skills_dir(self) -> Optional[Path]: """Return the active skills directory for preset skill overrides. @@ -1016,6 +1260,12 @@ def _register_skills( if not source_file.exists(): continue + # Use composed content if available (written by _register_commands + # for commands with non-replace strategies), otherwise the original. + composed_file = preset_dir / ".composed" / f"{cmd_name}.md" + if composed_file.exists(): + source_file = composed_file + # Derive the short command name (e.g. "specify" from "speckit.specify") raw_short_name = cmd_name if raw_short_name.startswith("speckit."): @@ -1257,43 +1507,81 @@ def install_from_directory( shutil.copytree(source_dir, dest_dir) - # Register command overrides with AI agents - registered_commands = self._register_commands(manifest, dest_dir) - - # Update corresponding skills when --ai-skills was previously used - registered_skills = self._register_skills(manifest, dest_dir) - - # Detect wrap commands before registry.add() so a read failure doesn't - # leave a partially-committed registry entry. - wrap_commands = [] - try: - from .agents import CommandRegistrar as _CR - _registrar = _CR() - for cmd_tmpl in manifest.templates: - if cmd_tmpl.get("type") != "command": - continue - cmd_file = dest_dir / cmd_tmpl["file"] - if not cmd_file.exists(): - continue - cmd_fm, _ = _registrar.parse_frontmatter(cmd_file.read_text(encoding="utf-8")) - if cmd_fm.get("strategy") == "wrap": - wrap_commands.append(cmd_tmpl["name"]) - except ImportError: - pass - + # Pre-register the preset so that composition resolution can see it + # in the priority stack when resolving composed command content. self.registry.add(manifest.id, { "version": manifest.version, "source": "local", "manifest_hash": manifest.get_hash(), "enabled": True, "priority": priority, - "registered_commands": registered_commands, - "registered_skills": registered_skills, - "wrap_commands": wrap_commands, + "registered_commands": {}, + "registered_skills": [], }) - for cmd_name in wrap_commands: - self._replay_wraps_for_command(cmd_name) + registered_commands: Dict[str, List[str]] = {} + registered_skills: List[str] = [] + try: + # Register command overrides with AI agents and persist the result + # immediately so cleanup can recover even if installation stops + # before later phases complete. + registered_commands = self._register_commands(manifest, dest_dir) + self.registry.update(manifest.id, { + "registered_commands": registered_commands, + }) + + # Update corresponding skills when --ai-skills was previously used + # and persist that result as well. + registered_skills = self._register_skills(manifest, dest_dir) + self.registry.update(manifest.id, { + "registered_skills": registered_skills, + }) + except Exception: + # Roll back all side effects. Note: if _register_commands or + # _register_skills raised mid-way (e.g. I/O error after writing + # some files), registered_commands/registered_skills may be empty + # and some agent command files could be orphaned. Removing dest_dir + # (which contains .composed/) and the registry entry ensures the + # preset system is consistent even if orphaned files remain. + if registered_commands: + self._unregister_commands(registered_commands) + if registered_skills: + self._unregister_skills(registered_skills, dest_dir) + try: + if dest_dir.exists(): + shutil.rmtree(dest_dir) + except OSError: + pass # best-effort cleanup; don't mask the original error + self.registry.remove(manifest.id) + raise + + # Reconcile all affected commands from the full priority stack so that + # install order doesn't determine the winning command file. + # Apply the same extension-installed filter as _register_commands to + # avoid reconciling extension commands when the extension isn't installed. + extensions_dir = self.project_root / ".specify" / "extensions" + cmd_names = [] + for t in manifest.templates: + if t.get("type") != "command": + continue + name = t["name"] + parts = name.split(".") + if len(parts) >= 3 and parts[0] == "speckit": + ext_id = parts[1] + if not (extensions_dir / ext_id).is_dir(): + continue + cmd_names.append(name) + if cmd_names: + try: + self._reconcile_composed_commands(cmd_names) + self._reconcile_skills(cmd_names) + except Exception as exc: + import warnings + warnings.warn( + f"Post-install reconciliation failed for {manifest.id}: {exc}. " + f"Agent command files may not reflect the current priority stack.", + stacklevel=2, + ) return manifest @@ -1369,16 +1657,31 @@ def remove(self, pack_id: str) -> bool: # Restore original skills when preset is removed registered_skills = metadata.get("registered_skills", []) if metadata else [] registered_commands = metadata.get("registered_commands", {}) if metadata else {} - wrap_commands = metadata.get("wrap_commands", []) if metadata else [] pack_dir = self.presets_dir / pack_id - # _unregister_skills must run before directory deletion (reads preset files) + # Collect ALL command names before filtering for reconciliation, + # so commands registered only for skill-based agents are also reconciled. + # Also include aliases from the manifest as a safety net for registries + # populated by older versions that may not track aliases. + removed_cmd_names = set() + for cmd_names in registered_commands.values(): + removed_cmd_names.update(cmd_names) + manifest_path = pack_dir / "preset.yml" + if manifest_path.exists(): + try: + manifest = PresetManifest(manifest_path) + for tmpl in manifest.templates: + if tmpl.get("type") == "command": + for alias in tmpl.get("aliases", []): + if isinstance(alias, str): + removed_cmd_names.add(alias) + except PresetValidationError: + # Invalid manifest — skip alias extraction; primary command + # names from registered_commands are still unregistered. + pass + if registered_skills: self._unregister_skills(registered_skills, pack_dir) - # When _unregister_skills has already handled skill-agent files, strip - # those entries from registered_commands to avoid double-deletion. - # (When registered_skills is empty, skill-agent entries in - # registered_commands are the only deletion path for those files.) try: from .agents import CommandRegistrar except ImportError: @@ -1390,43 +1693,29 @@ def remove(self, pack_id: str) -> bool: if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md" } - # Delete the preset directory before mutating the registry so a - # filesystem failure cannot leave files on disk without a registry entry. + # Unregister non-skill command files from AI agents. + if registered_commands: + self._unregister_commands(registered_commands) + if pack_dir.exists(): shutil.rmtree(pack_dir) - # Remove from registry before replaying so _replay_wraps_for_command sees - # the post-removal registry state. self.registry.remove(pack_id) - # Separate wrap commands from non-wrap commands in registered_commands. - non_wrap_commands = { - agent_name: [c for c in cmd_names if c not in wrap_commands] - for agent_name, cmd_names in registered_commands.items() - } - non_wrap_commands = {k: v for k, v in non_wrap_commands.items() if v} - - # Unregister non-wrap command files from AI agents. - if non_wrap_commands: - self._unregister_commands(non_wrap_commands) - - # For each wrapped command, either re-compose remaining wraps or delete. - for cmd_name in wrap_commands: - remaining = [ - pid for pid, meta in self.registry.list().items() - if cmd_name in meta.get("wrap_commands", []) - ] - if remaining: - self._replay_wraps_for_command(cmd_name) - else: - # No wrap presets remain — delete the agent file entirely. - wrap_agent_commands = { - agent_name: [c for c in cmd_names if c == cmd_name] - for agent_name, cmd_names in registered_commands.items() - } - wrap_agent_commands = {k: v for k, v in wrap_agent_commands.items() if v} - if wrap_agent_commands: - self._unregister_commands(wrap_agent_commands) + # Reconcile: if other presets still provide these commands, + # re-resolve from the remaining stack so the next layer takes effect. + if removed_cmd_names: + try: + self._reconcile_composed_commands(list(removed_cmd_names)) + self._reconcile_skills(list(removed_cmd_names)) + except Exception as exc: + import warnings + warnings.warn( + f"Post-removal reconciliation failed for {pack_id}: {exc}. " + f"Agent command files may be stale; reinstall affected presets " + f"or run 'specify preset add' to refresh.", + stacklevel=2, + ) return True @@ -2036,6 +2325,21 @@ def __init__(self, project_root: Path): self.presets_dir = project_root / ".specify" / "presets" self.overrides_dir = self.templates_dir / "overrides" self.extensions_dir = project_root / ".specify" / "extensions" + self._manifest_cache: Dict[str, Optional["PresetManifest"]] = {} + + def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]: + """Get a cached preset manifest, parsing it on first access.""" + key = str(pack_dir) + if key not in self._manifest_cache: + manifest_path = pack_dir / "preset.yml" + if manifest_path.exists(): + try: + self._manifest_cache[key] = PresetManifest(manifest_path) + except PresetValidationError: + self._manifest_cache[key] = None + else: + self._manifest_cache[key] = None + return self._manifest_cache[key] def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: """Build unified list of registered and unregistered extensions sorted by priority. @@ -2079,6 +2383,19 @@ def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]: all_extensions.sort(key=lambda x: (x[0], x[1])) return all_extensions + @staticmethod + def _core_stem(template_name: str) -> Optional[str]: + """Extract the stem for core command lookup. + + Commands use dot notation (e.g. ``speckit.specify``), but core + command files are named by stem (e.g. ``specify.md``). Returns + the stem if *template_name* follows the ``speckit.`` pattern, + or ``None`` otherwise. + """ + if template_name.startswith("speckit."): + return template_name[len("speckit."):] + return None + def resolve( self, template_name: str, @@ -2156,6 +2473,12 @@ def resolve( core = self.templates_dir / "commands" / f"{template_name}.md" if core.exists(): return core + # Fallback: speckit..md + stem = self._core_stem(template_name) + if stem: + core = self.templates_dir / "commands" / f"{stem}.md" + if core.exists(): + return core elif template_type == "script": core = self.templates_dir / "scripts" / f"{template_name}{ext}" if core.exists(): @@ -2173,6 +2496,10 @@ def resolve( candidate = _core_pack / "templates" / f"{template_name}.md" elif template_type == "command": candidate = _core_pack / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = _core_pack / "commands" / f"{stem}.md" elif template_type == "script": candidate = _core_pack / "scripts" / f"{template_name}{ext}" else: @@ -2186,6 +2513,10 @@ def resolve( candidate = repo_root / "templates" / f"{template_name}.md" elif template_type == "command": candidate = repo_root / "templates" / "commands" / f"{template_name}.md" + if not candidate.exists(): + stem = self._core_stem(template_name) + if stem: + candidate = repo_root / "templates" / "commands" / f"{stem}.md" elif template_type == "script": candidate = repo_root / "scripts" / f"{template_name}{ext}" else: @@ -2317,3 +2648,428 @@ def resolve_with_source( continue return {"path": resolved_str, "source": "core"} + + def collect_all_layers( + self, + template_name: str, + template_type: str = "template", + ) -> List[Dict[str, Any]]: + """Collect all layers in the priority stack for a template. + + Returns layers from highest priority (checked first) to lowest priority. + Each layer is a dict with 'path', 'source', and 'strategy' keys. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + List of layer dicts ordered highest-to-lowest priority. + """ + if template_type == "template": + subdirs = ["templates", ""] + elif template_type == "command": + subdirs = ["commands"] + elif template_type == "script": + subdirs = ["scripts"] + else: + subdirs = [""] + + ext = ".md" + if template_type == "script": + ext = ".sh" + + layers: List[Dict[str, Any]] = [] + + def _find_in_subdirs(base_dir: Path) -> Optional[Path]: + for subdir in subdirs: + if subdir: + candidate = base_dir / subdir / f"{template_name}{ext}" + else: + candidate = base_dir / f"{template_name}{ext}" + if candidate.exists(): + return candidate + return None + + # Priority 1: Project-local overrides (always "replace" strategy) + if template_type == "script": + override = self.overrides_dir / "scripts" / f"{template_name}{ext}" + else: + override = self.overrides_dir / f"{template_name}{ext}" + if override.exists(): + layers.append({ + "path": override, + "source": "project override", + "strategy": "replace", + }) + + # Priority 2: Installed presets (sorted by priority — lower number = higher precedence) + if self.presets_dir.exists(): + registry = PresetRegistry(self.presets_dir) + for pack_id, metadata in registry.list_by_priority(): + pack_dir = self.presets_dir / pack_id + # Read strategy and manifest file path from preset manifest + strategy = "replace" + manifest_file_path = None + manifest_has_strategy = False + manifest_found_entry = False + manifest = self._get_manifest(pack_dir) + if manifest: + for tmpl in manifest.templates: + if (tmpl.get("name") == template_name + and tmpl.get("type") == template_type): + strategy = tmpl.get("strategy", "replace") + manifest_has_strategy = "strategy" in tmpl + manifest_file_path = tmpl.get("file") + manifest_found_entry = True + break + # Use manifest file path if specified, otherwise convention-based + # lookup — but only when the manifest doesn't exist or doesn't + # list this template, so preset.yml stays authoritative. + candidate = None + if manifest_file_path: + manifest_candidate = pack_dir / manifest_file_path + if manifest_candidate.exists(): + candidate = manifest_candidate + # Explicit file path that doesn't exist: skip convention + # fallback to avoid masking typos or picking up unintended files. + elif not manifest_found_entry: + # Manifest doesn't list this template — check convention paths + candidate = _find_in_subdirs(pack_dir) + if candidate: + # Legacy fallback: if manifest doesn't explicitly declare a + # strategy, check the command file's frontmatter for any valid + # strategy. Skip when the manifest entry includes strategy key + # (even if it's "replace") to avoid overriding explicit declarations. + if not manifest_has_strategy and strategy == "replace" and template_type == "command": + try: + cmd_content = candidate.read_text(encoding="utf-8") + lines = cmd_content.splitlines(keepends=True) + if lines and lines[0].rstrip("\r\n") == "---": + fence_end = -1 + for fi, fline in enumerate(lines[1:], start=1): + if fline.rstrip("\r\n") == "---": + fence_end = fi + break + if fence_end > 0: + fm_text = "".join(lines[1:fence_end]) + fm_data = yaml.safe_load(fm_text) + if isinstance(fm_data, dict): + fm_strategy = fm_data.get("strategy") + if isinstance(fm_strategy, str) and fm_strategy.lower() in VALID_PRESET_STRATEGIES: + strategy = fm_strategy.lower() + except (yaml.YAMLError, OSError): + # Best-effort legacy frontmatter parsing: keep default + # strategy ("replace") when content is unreadable/invalid. + pass + version = metadata.get("version", "?") if metadata else "?" + layers.append({ + "path": candidate, + "source": f"{pack_id} v{version}", + "strategy": strategy, + }) + + # Priority 3: Extension-provided templates (always "replace") + for _priority, ext_id, ext_meta in self._get_all_extensions_by_priority(): + ext_dir = self.extensions_dir / ext_id + if not ext_dir.is_dir(): + continue + # Try convention-based lookup first + candidate = _find_in_subdirs(ext_dir) + # If not found and this is a command, check extension manifest + if candidate is None and template_type == "command": + ext_manifest_path = ext_dir / "extension.yml" + if ext_manifest_path.exists(): + try: + from .extensions import ExtensionManifest, ValidationError as ExtValidationError + ext_manifest = ExtensionManifest(ext_manifest_path) + for cmd in ext_manifest.commands: + if cmd.get("name") == template_name: + cmd_file = cmd.get("file") + if cmd_file: + c = ext_dir / cmd_file + if c.exists(): + candidate = c + break + except (ExtValidationError, yaml.YAMLError): + # Invalid extension manifest — fall back to + # convention-based lookup (already attempted above). + pass + if candidate: + if ext_meta: + version = ext_meta.get("version", "?") + source = f"extension:{ext_id} v{version}" + else: + source = f"extension:{ext_id} (unregistered)" + layers.append({ + "path": candidate, + "source": source, + "strategy": "replace", + }) + + # Priority 4: Core templates (always "replace") + core = None + if template_type == "template": + c = self.templates_dir / f"{template_name}.md" + if c.exists(): + core = c + elif template_type == "command": + c = self.templates_dir / "commands" / f"{template_name}.md" + if c.exists(): + core = c + else: + # Fallback: speckit..md + stem = self._core_stem(template_name) + if stem: + c = self.templates_dir / "commands" / f"{stem}.md" + if c.exists(): + core = c + elif template_type == "script": + c = self.templates_dir / "scripts" / f"{template_name}{ext}" + if c.exists(): + core = c + if core: + layers.append({ + "path": core, + "source": "core", + "strategy": "replace", + }) + else: + # Priority 5: Bundled core_pack (wheel install) or repo-root + # templates (source-checkout), matching resolve()'s tier-5 fallback. + bundled = self._find_bundled_core(template_name, template_type, ext) + if bundled: + layers.append({ + "path": bundled, + "source": "core (bundled)", + "strategy": "replace", + }) + + return layers + + def _find_bundled_core( + self, + template_name: str, + template_type: str, + ext: str, + ) -> Optional[Path]: + """Find a core template from the bundled pack or source checkout. + + Mirrors the tier-5 fallback logic in ``resolve()`` so that + ``collect_all_layers()`` can locate base layers even when + ``.specify/templates/`` doesn't contain the core file. + """ + try: + from specify_cli import _locate_core_pack + except ImportError: + return None + + stem = self._core_stem(template_name) + names = [template_name] + if stem and stem != template_name: + names.append(stem) + + core_pack = _locate_core_pack() + if core_pack is not None: + for name in names: + if template_type == "template": + c = core_pack / "templates" / f"{name}.md" + elif template_type == "command": + c = core_pack / "commands" / f"{name}.md" + elif template_type == "script": + c = core_pack / "scripts" / f"{name}{ext}" + else: + c = core_pack / f"{name}.md" + if c.exists(): + return c + else: + repo_root = Path(__file__).parent.parent.parent + for name in names: + if template_type == "template": + c = repo_root / "templates" / f"{name}.md" + elif template_type == "command": + c = repo_root / "templates" / "commands" / f"{name}.md" + elif template_type == "script": + c = repo_root / "scripts" / f"{name}{ext}" + else: + c = repo_root / f"{name}.md" + if c.exists(): + return c + return None + + def resolve_content( + self, + template_name: str, + template_type: str = "template", + ) -> Optional[str]: + """Resolve a template name and return composed content. + + Walks the priority stack and composes content using strategies: + - replace (default): highest-priority content wins entirely + - prepend: content is placed before lower-priority content + - append: content is placed after lower-priority content + - wrap: content contains {CORE_TEMPLATE} placeholder replaced + with lower-priority content (or $CORE_SCRIPT for scripts) + + Composition is recursive — multiple composing presets chain. + + Args: + template_name: Template name (e.g., "spec-template") + template_type: Template type ("template", "command", or "script") + + Returns: + Composed content string, or None if not found + """ + layers = self.collect_all_layers(template_name, template_type) + if not layers: + return None + + # If the top (highest-priority) layer is replace, it wins entirely — + # lower layers are irrelevant regardless of their strategies. + if layers[0]["strategy"] == "replace": + return layers[0]["path"].read_text(encoding="utf-8") + + # Composition: build content bottom-up from the effective base. + # The base is the nearest replace layer scanning from highest priority + # downward. Only layers above the base contribute to composition. + # + # layers is ordered highest-priority first. We process in reverse. + reversed_layers = list(reversed(layers)) + + # Find the effective base: scan from highest priority (layers[0]) downward + # to find the nearest replace layer. Only compose layers above that base. + # layers is highest-priority first; reversed_layers is lowest first. + base_layer_idx = None # index in layers[] (highest-priority first) + for idx, layer in enumerate(layers): + if layer["strategy"] == "replace": + base_layer_idx = idx + break + + if base_layer_idx is None: + return None # no replace base found + + # Convert to reversed_layers index + base_reversed_idx = len(layers) - 1 - base_layer_idx + content = layers[base_layer_idx]["path"].read_text(encoding="utf-8") + # Compose only the layers above the base (higher priority = lower index in layers, + # higher index in reversed_layers). Process bottom-up from base+1. + start_idx = base_reversed_idx + 1 + + # For command composition, strip frontmatter from each layer to avoid + # leaking YAML metadata into the composed body. The highest-priority + # layer's frontmatter will be reattached at the end. + is_command = template_type == "command" + top_frontmatter_text = None + base_frontmatter_text = None + + def _split_frontmatter(text: str) -> tuple: + """Return (frontmatter_block_with_fences, body) or (None, text). + + Uses line-based fence detection (fence must be ``---`` on its + own line) to avoid false matches on ``---`` inside YAML values. + """ + lines = text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return None, text + + fence_end = -1 + for i, line in enumerate(lines[1:], start=1): + if line.rstrip("\r\n") == "---": + fence_end = i + break + + if fence_end == -1: + return None, text + + fm_block = "".join(lines[:fence_end + 1]).rstrip("\r\n") + body = "".join(lines[fence_end + 1:]) + return fm_block, body + + if is_command: + fm, body = _split_frontmatter(content) + if fm: + top_frontmatter_text = fm + base_frontmatter_text = fm + content = body + + # Apply composition layers from bottom to top + for layer in reversed_layers[start_idx:]: + layer_content = layer["path"].read_text(encoding="utf-8") + strategy = layer["strategy"] + + if is_command: + fm, layer_body = _split_frontmatter(layer_content) + layer_content = layer_body + # Track the highest-priority frontmatter seen; + # replace layers reset both top and base frontmatter since + # they replace the entire command including metadata. + if strategy == "replace": + top_frontmatter_text = fm + base_frontmatter_text = fm + elif fm: + top_frontmatter_text = fm + + if strategy == "replace": + content = layer_content + elif strategy == "prepend": + content = layer_content + "\n\n" + content + elif strategy == "append": + content = content + "\n\n" + layer_content + elif strategy == "wrap": + if template_type == "script": + placeholder = "$CORE_SCRIPT" + else: + placeholder = "{CORE_TEMPLATE}" + if placeholder not in layer_content: + raise PresetValidationError( + f"Wrap strategy in '{layer['source']}' is missing " + f"the {placeholder} placeholder. The wrapper must " + f"contain {placeholder} to indicate where the " + f"lower-priority content should be inserted." + ) + content = layer_content.replace(placeholder, content) + + # Reattach the highest-priority frontmatter for commands, + # inheriting scripts/agent_scripts from the base if missing + # and stripping the strategy key (internal-only, not for agent output). + if is_command and top_frontmatter_text: + def _parse_fm_yaml(fm_block: str) -> dict: + """Parse YAML from a frontmatter block (with --- fences).""" + lines = fm_block.splitlines() + # Parse only interior lines (between --- fences) + if len(lines) >= 2: + yaml_lines = lines[1:-1] + else: + yaml_lines = [] + try: + return yaml.safe_load("\n".join(yaml_lines)) or {} + except yaml.YAMLError: + return {} + + top_fm = _parse_fm_yaml(top_frontmatter_text) + + # Inherit scripts/agent_scripts from base frontmatter if missing + if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text: + base_fm = _parse_fm_yaml(base_frontmatter_text) + for key in ("scripts", "agent_scripts"): + if key not in top_fm and key in base_fm: + top_fm[key] = base_fm[key] + + # Strip strategy key — it's an internal composition directive, + # not meant for rendered agent command files + top_fm.pop("strategy", None) + + if top_fm: + top_frontmatter_text = ( + "---\n" + + yaml.safe_dump(top_fm, sort_keys=False).strip() + + "\n---" + ) + else: + # Empty frontmatter — omit rather than emitting {} + top_frontmatter_text = None + + if top_frontmatter_text: + content = top_frontmatter_text + "\n\n" + content + + return content diff --git a/tests/test_presets.py b/tests/test_presets.py index d913c3b195..64bdc1f0b5 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -3666,647 +3666,736 @@ def _make_wrap_preset_dir( return preset_dir -class TestReplayWrapsForCommand: - """Tests for PresetManager._replay_wraps_for_command().""" - def test_replay_no_op_when_no_wrap_presets(self, project_dir): - """replay does nothing when no presets declare wrap_commands for the command.""" - manager = PresetManager(project_dir) - # Should not raise - manager._replay_wraps_for_command("speckit.specify") - - def test_replay_no_op_when_core_missing(self, project_dir, temp_dir): - """replay exits gracefully when resolve_core returns None.""" - from specify_cli.agents import CommandRegistrar - import copy - - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.nonexistent-cmd", "pre-a", "post-a") - installed = project_dir / ".specify" / "presets" / "preset-a" - import shutil as _shutil - _shutil.copytree(preset_dir, installed) +class TestCompositionStrategyValidation: + """Test strategy field validation in PresetManifest.""" - manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.nonexistent-cmd"], - }) + def test_valid_replace_strategy(self, temp_dir, valid_pack_data): + """Test that replace strategy is accepted.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "replace" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "replace" - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) + def test_valid_prepend_strategy(self, temp_dir, valid_pack_data): + """Test that prepend strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "prepend" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "prepend" - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - # No core file exists for this command — replay should return without writing - manager._replay_wraps_for_command("speckit.nonexistent-cmd") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + def test_valid_append_strategy(self, temp_dir, valid_pack_data): + """Test that append strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "append" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "append" - assert not (agent_dir / "speckit.nonexistent-cmd.md").exists() + def test_valid_wrap_strategy(self, temp_dir, valid_pack_data): + """Test that wrap strategy is accepted for templates.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "wrap" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + (temp_dir / "templates").mkdir(exist_ok=True) + (temp_dir / "templates" / "spec-template.md").write_text("test") + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "wrap" - def test_replay_single_preset_writes_composed_output(self, project_dir, temp_dir): - """Single wrap preset: replay writes pre + core + post to agent dirs.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil + def test_default_strategy_is_replace(self, pack_dir): + """Test that omitting strategy defaults to replace (key is absent).""" + manifest = PresetManifest(pack_dir / "preset.yml") + # Strategy key should not be present in the manifest data + assert "strategy" not in manifest.templates[0] + # But consumers should treat missing strategy as "replace" + assert manifest.templates[0].get("strategy", "replace") == "replace" + + def test_invalid_strategy_rejected(self, temp_dir, valid_pack_data): + """Test that invalid strategy values are rejected.""" + valid_pack_data["provides"]["templates"][0]["strategy"] = "merge" + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy"): + PresetManifest(manifest_path) - # Core template - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore body\n") + def test_prepend_rejected_for_scripts(self, temp_dir, valid_pack_data): + """Test that prepend strategy is rejected for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "prepend", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): + PresetManifest(manifest_path) - # Install preset-a - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") - installed = project_dir / ".specify" / "presets" / "preset-a" - _shutil.copytree(preset_dir, installed) + def test_append_rejected_for_scripts(self, temp_dir, valid_pack_data): + """Test that append strategy is rejected for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "append", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + with pytest.raises(PresetValidationError, match="Invalid strategy.*for script"): + PresetManifest(manifest_path) - manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + def test_wrap_accepted_for_scripts(self, temp_dir, valid_pack_data): + """Test that wrap strategy is accepted for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "wrap", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "wrap" + + def test_replace_accepted_for_scripts(self, temp_dir, valid_pack_data): + """Test that replace strategy is accepted for scripts.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "script", + "name": "create-new-feature", + "file": "scripts/create-new-feature.sh", + "strategy": "replace", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "replace" + + def test_prepend_accepted_for_commands(self, temp_dir, valid_pack_data): + """Test that prepend strategy is accepted for commands.""" + valid_pack_data["provides"]["templates"] = [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + "strategy": "prepend", + }] + manifest_path = temp_dir / "preset.yml" + with open(manifest_path, 'w') as f: + yaml.dump(valid_pack_data, f) + manifest = PresetManifest(manifest_path) + assert manifest.templates[0]["strategy"] == "prepend" - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - written = (agent_dir / "speckit.specify.md").read_text() - assert "[pre-a]" in written - assert "core body" in written - assert "[post-a]" in written - assert "{CORE_TEMPLATE}" not in written - assert "strategy" not in written +class TestResolveContent: + """Test PresetResolver.resolve_content() composition.""" - def test_replay_uses_manifest_command_file_mapping(self, project_dir, temp_dir): - """Replay reads wrapper files from preset.yml instead of assuming command-name paths.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil + def test_resolve_content_core_template(self, project_dir): + """Test resolve_content returns core template when no composition.""" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Core Spec Template" in content - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - preset_dir = _make_wrap_preset_dir( - temp_dir, - "preset-a", - "speckit.specify", - "pre-a", - "post-a", - file_rel="commands/custom-wrapper.md", - ) - installed = project_dir / ".specify" / "presets" / "preset-a" - _shutil.copytree(preset_dir, installed) + def test_resolve_content_nonexistent(self, project_dir): + """Test resolve_content returns None for nonexistent template.""" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("nonexistent") + assert content is None + def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with default replace strategy.""" manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory( + _create_pack(temp_dir, valid_pack_data, "replace-pack", + "# Replaced Content\n"), + "0.1.5" + ) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Replaced Content" in content + assert "Core Spec Template" not in content + + def test_resolve_content_append_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with append strategy.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n") - written = (agent_dir / "speckit.specify.md").read_text() - assert "[pre-a]" in written - assert "CORE" in written - assert "[post-a]" in written + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") - def test_replay_resolves_extension_core_via_manifest_mapping(self, project_dir, temp_dir): - """Replay finds extension core commands whose manifest file differs from command name.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Core Spec Template" in content + assert "Appended Section" in content + # Core should come first, appended after + assert content.index("Core Spec Template") < content.index("Appended Section") + + def test_resolve_content_prepend_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with prepend strategy.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "prepend-pack", "name": "Prepend"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] + } + pack_dir = temp_dir / "prepend-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Security Header\n") - ext_dir = project_dir / ".specify" / "extensions" / "selftest" - cmd_dir = ext_dir / "commands" - cmd_dir.mkdir(parents=True, exist_ok=True) - (cmd_dir / "selftest.md").write_text( - "---\ndescription: selftest core\n---\n\nEXTENSION-CORE\n" - ) - (ext_dir / "extension.yml").write_text( - "schema_version: '1.0'\n" - "extension:\n id: selftest\n name: Self-Test\n version: 1.0.0\n" - " description: test\n author: test\n repository: https://example.com\n" - " license: MIT\n" - "requires:\n speckit_version: '>=0.2.0'\n" - "provides:\n" - " commands:\n" - " - name: speckit.selftest.extension\n" - " file: commands/selftest.md\n" - " description: Selftest command\n" - ) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") - preset_dir = _make_wrap_preset_dir( - temp_dir, "preset-a", "speckit.selftest.extension", "pre-a", "post-a" + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Security Header" in content + assert "Core Spec Template" in content + # Prepended content should come first + assert content.index("Security Header") < content.index("Core Spec Template") + + def test_resolve_content_wrap_strategy(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for templates.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-pack", "name": "Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] + } + pack_dir = temp_dir / "wrap-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text( + "# Wrapper Start\n\n{CORE_TEMPLATE}\n\n# Wrapper End\n" ) - installed = project_dir / ".specify" / "presets" / "preset-a" - _shutil.copytree(preset_dir, installed) manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.selftest.extension"], - }) + manager.install_from_directory(pack_dir, "0.1.5") - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Wrapper Start" in content + assert "Core Spec Template" in content + assert "Wrapper End" in content + # Wrapper should surround core + assert content.index("Wrapper Start") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Wrapper End") + + def test_resolve_content_wrap_strategy_script(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with wrap strategy for scripts uses $CORE_SCRIPT.""" + # Create core script + scripts_dir = project_dir / ".specify" / "templates" / "scripts" + scripts_dir.mkdir(parents=True, exist_ok=True) + (scripts_dir / "test-script.sh").write_text("echo 'core script'\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "script-wrap", "name": "Script Wrap"} + pack_data["provides"] = { + "templates": [{ + "type": "script", + "name": "test-script", + "file": "scripts/test-script.sh", + "strategy": "wrap", + }] } - try: - manager._replay_wraps_for_command("speckit.selftest.extension") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.selftest.extension.md").read_text() - assert "[pre-a]" in written - assert "EXTENSION-CORE" in written - assert "[post-a]" in written - - def test_replay_priority_order_lower_number_outermost(self, project_dir, temp_dir): - """Two wrap presets: lower priority number = outermost wrapper.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - for pid in ("preset-outer", "preset-inner"): - src = _make_wrap_preset_dir(temp_dir, pid, "speckit.specify", f"pre-{pid}", f"post-{pid}") - _shutil.copytree(src, project_dir / ".specify" / "presets" / pid) + pack_dir = temp_dir / "script-wrap" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "scripts").mkdir() + (pack_dir / "scripts" / "test-script.sh").write_text( + "#!/bin/bash\necho 'before'\n$CORE_SCRIPT\necho 'after'\n" + ) manager = PresetManager(project_dir) - # preset-outer has priority 1 (highest precedence = outermost) - # preset-inner has priority 10 (lowest precedence = innermost) - for pid, pri in (("preset-outer", 1), ("preset-inner", 10)): - manager.registry.add(pid, { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": pri, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory(pack_dir, "0.1.5") - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("test-script", "script") + assert content is not None + assert "echo 'before'" in content + assert "echo 'core script'" in content + assert "echo 'after'" in content + + def test_resolve_content_multi_preset_chain(self, project_dir, temp_dir, valid_pack_data): + """Test multi-preset composition chain: prepend + append stacking.""" + # Create preset A (priority 1): prepend security header + pack_a_data = {**valid_pack_data} + pack_a_data["preset"] = {**valid_pack_data["preset"], "id": "preset-a", "name": "A"} + pack_a_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "prepend", + }] } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - # Outermost (preset-outer, p=1) wraps everything; innermost (preset-inner, p=10) is next - outer_pre = written.index("[pre-preset-outer]") - inner_pre = written.index("[pre-preset-inner]") - core_pos = written.index("CORE") - inner_post = written.index("[post-preset-inner]") - outer_post = written.index("[post-preset-outer]") - assert outer_pre < inner_pre < core_pos < inner_post < outer_post - - def test_replay_install_order_independent(self, project_dir, temp_dir): - """Nesting order is determined by priority, not install order.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - for pid in ("preset-a", "preset-b"): - src = _make_wrap_preset_dir(temp_dir, pid, "speckit.specify", f"pre-{pid}", f"post-{pid}") - _shutil.copytree(src, project_dir / ".specify" / "presets" / pid) + pack_a_dir = temp_dir / "preset-a" + pack_a_dir.mkdir() + with open(pack_a_dir / "preset.yml", 'w') as f: + yaml.dump(pack_a_data, f) + (pack_a_dir / "templates").mkdir() + (pack_a_dir / "templates" / "spec-template.md").write_text("## Security Header\n") + + # Create preset B (priority 2): append compliance footer + pack_b_data = {**valid_pack_data} + pack_b_data["preset"] = {**valid_pack_data["preset"], "id": "preset-b", "name": "B"} + pack_b_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] + } + pack_b_dir = temp_dir / "preset-b" + pack_b_dir.mkdir() + with open(pack_b_dir / "preset.yml", 'w') as f: + yaml.dump(pack_b_data, f) + (pack_b_dir / "templates").mkdir() + (pack_b_dir / "templates" / "spec-template.md").write_text("## Compliance Footer\n") manager = PresetManager(project_dir) - # preset-a priority=5 (outermost), preset-b priority=10 (innermost) - # Install in reverse order to verify install order doesn't affect nesting - for pid, pri in (("preset-b", 10), ("preset-a", 5)): - manager.registry.add(pid, { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": pri, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory(pack_a_dir, "0.1.5", priority=1) + manager.install_from_directory(pack_b_dir, "0.1.5", priority=2) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + # Result: + + + assert "Security Header" in content + assert "Core Spec Template" in content + assert "Compliance Footer" in content + assert content.index("Security Header") < content.index("Core Spec Template") + assert content.index("Core Spec Template") < content.index("Compliance Footer") + + def test_resolve_content_override_trumps_composition(self, project_dir, temp_dir, valid_pack_data): + """Test that project overrides trump composition (replace at top priority).""" + # Install a composing preset + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - try: - manager._replay_wraps_for_command("speckit.specify") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - a_pre = written.index("[pre-preset-a]") - b_pre = written.index("[pre-preset-b]") - core_pos = written.index("CORE") - b_post = written.index("[post-preset-b]") - a_post = written.index("[post-preset-a]") - # preset-a (p=5) is outermost regardless of install order - assert a_pre < b_pre < core_pos < b_post < a_post - - def test_replay_updates_skill_outputs(self, project_dir, temp_dir): - """Replay also rewrites SKILL.md-backed agent outputs.""" - import json - import shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") - _shutil.copytree(preset_dir, project_dir / ".specify" / "presets" / "preset-a") + pack_dir = temp_dir / "append-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Appended\n") manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) - - skills_dir = project_dir / ".claude" / "skills" - skill_subdir = skills_dir / "speckit-specify" - skill_subdir.mkdir(parents=True) - (skill_subdir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n") - (project_dir / ".specify" / "init-options.json").write_text( - json.dumps({"ai": "claude", "ai_skills": True}) - ) - - manager._replay_wraps_for_command("speckit.specify") - - written = (skill_subdir / "SKILL.md").read_text() - assert "[pre-a]" in written - assert "CORE" in written - assert "[post-a]" in written - - def test_replay_applies_integration_post_processing_to_skill(self, project_dir, temp_dir): - """_replay_skill_override must call post_process_skill_content, matching _register_skills.""" - import json - import shutil as _shutil + manager.install_from_directory(pack_dir, "0.1.5") - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + # Create project override (replaces everything) + overrides_dir = project_dir / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True) + (overrides_dir / "spec-template.md").write_text("# Override Only\n") - preset_dir = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre-a", "post-a") - _shutil.copytree(preset_dir, project_dir / ".specify" / "presets" / "preset-a") + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content is not None + assert "Override Only" in content + # Override replaces, so appended content should not be visible + assert "Core Spec Template" not in content + + def test_resolve_content_command_type(self, project_dir, temp_dir, valid_pack_data): + """Test resolve_content with command template type.""" + # Create core command using stem naming (matches real layout: plan.md, not speckit.plan.md) + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "plan.md").write_text("# Core Plan Command\n") + + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "cmd-append", "name": "CmdAppend"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.plan", + "file": "commands/speckit.plan.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "cmd-append" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.plan.md").write_text("## Additional Instructions\n") manager = PresetManager(project_dir) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": ["speckit.specify"], - }) + manager.install_from_directory(pack_dir, "0.1.5") - skills_dir = project_dir / ".claude" / "skills" - skill_subdir = skills_dir / "speckit-specify" - skill_subdir.mkdir(parents=True) - (skill_subdir / "SKILL.md").write_text("---\nname: speckit-specify\n---\n\nold\n") - (project_dir / ".specify" / "init-options.json").write_text( - json.dumps({"ai": "claude", "ai_skills": True}) + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.plan", "command") + assert content is not None + assert "Core Plan Command" in content + assert "Additional Instructions" in content + + def test_resolve_content_command_frontmatter_stripping(self, project_dir, temp_dir, valid_pack_data): + """Test that command composition strips frontmatter from lower layers + and reattaches only the highest-priority frontmatter.""" + # Create core command with frontmatter + commands_dir = project_dir / ".specify" / "templates" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + (commands_dir / "check.md").write_text( + "---\ndescription: Core check command\n---\nCore body content\n" ) - manager._replay_wraps_for_command("speckit.specify") - - # ClaudeIntegration.post_process_skill_content injects these flags. - # Their presence proves the integration hook ran during replay. - written = (skill_subdir / "SKILL.md").read_text() - assert "disable-model-invocation: false" in written, ( - "_replay_skill_override must call post_process_skill_content " - "(same as _register_skills)" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "fm-test", "name": "FmTest"} + pack_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.check", + "file": "commands/speckit.check.md", + "strategy": "append", + }] + } + pack_dir = temp_dir / "fm-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "commands").mkdir() + (pack_dir / "commands" / "speckit.check.md").write_text( + "---\ndescription: Preset check override\n---\nPreset body content\n" ) + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") -class TestInstallRemoveWrapLifecycle: - """Tests for wrap_commands stored on install and replayed on remove.""" - - def _setup_agent(self, project_dir, registrar, agent_configs_dict): - """Register a test markdown agent and return its commands dir.""" - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - agent_configs_dict["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("speckit.check", "command") + assert content is not None + # Should have the preset (highest-priority) frontmatter + assert "Preset check override" in content + # Should have both bodies + assert "Core body content" in content + assert "Preset body content" in content + # Core frontmatter should NOT appear in the body + assert content.count("---") == 2 # only one frontmatter block (opening + closing) + + def test_resolve_content_blank_line_separator(self, project_dir, temp_dir, valid_pack_data): + """Test that prepend/append use blank line separator.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "sep-test", "name": "SepTest"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - return agent_dir + pack_dir = temp_dir / "sep-test" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("appended") - def test_install_stores_wrap_commands_in_registry(self, project_dir, temp_dir): - """install_from_directory stores wrap_commands in the registry entry.""" - from specify_cli.agents import CommandRegistrar - import copy - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\ncore\n") - - preset_src = _make_wrap_preset_dir(temp_dir, "preset-a", "speckit.specify", "pre", "post") + manager = PresetManager(project_dir) + manager.install_from_directory(pack_dir, "0.1.5") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + # Should have blank line separator + assert "\n\n" in content + + def test_resolve_content_replace_over_wrap(self, project_dir, temp_dir, valid_pack_data): + """Top-priority replace layer should win even if a lower layer uses wrap.""" + # Install a low-priority wrap preset (with no placeholder — would fail if evaluated) + wrap_data = {**valid_pack_data} + wrap_data["preset"] = {**valid_pack_data["preset"], "id": "wrap-lo", "name": "WrapLo"} + wrap_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "wrap", + }] } - try: - manager = PresetManager(project_dir) - manager.install_from_directory(preset_src, "0.1.0", priority=10) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - meta = manager.registry.get("preset-a") - assert "wrap_commands" in meta - assert "speckit.specify" in meta["wrap_commands"] - - def test_install_replay_produces_correct_nested_output(self, project_dir, temp_dir): - """After installing two wrap presets, agent file contains correctly nested output.""" - from specify_cli.agents import CommandRegistrar - import copy, shutil as _shutil - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + wrap_dir = temp_dir / "wrap-lo" + wrap_dir.mkdir() + with open(wrap_dir / "preset.yml", "w") as f: + yaml.dump(wrap_data, f) + (wrap_dir / "templates").mkdir() + # Intentionally missing {CORE_TEMPLATE} — would error if composition ran + (wrap_dir / "templates" / "spec-template.md").write_text("wrapper without placeholder") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + manager = PresetManager(project_dir) + manager.install_from_directory(wrap_dir, "0.1.5", priority=10) + + # Install a high-priority replace preset + rep_data = {**valid_pack_data} + rep_data["preset"] = {**valid_pack_data["preset"], "id": "rep-hi", "name": "RepHi"} + rep_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + }] } - try: - manager = PresetManager(project_dir) - # Install outermost first (priority=5), then innermost (priority=10) - outer_src = _make_wrap_preset_dir(temp_dir, "preset-outer", "speckit.specify", "OUTER-PRE", "OUTER-POST") - # Rename to avoid id conflict with fixture - inner_src = _make_wrap_preset_dir(temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST") - manager.install_from_directory(outer_src, "0.1.0", priority=5) - manager.install_from_directory(inner_src, "0.1.0", priority=10) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) - - written = (agent_dir / "speckit.specify.md").read_text() - outer_pre = written.index("OUTER-PRE") - inner_pre = written.index("INNER-PRE") - core_pos = written.index("CORE") - inner_post = written.index("INNER-POST") - outer_post = written.index("OUTER-POST") - assert outer_pre < inner_pre < core_pos < inner_post < outer_post - - def test_remove_replays_remaining_wraps(self, project_dir, temp_dir): - """Removing one wrap preset re-composes the remaining wraps correctly.""" - from specify_cli.agents import CommandRegistrar - import copy - - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + rep_dir = temp_dir / "rep-hi" + rep_dir.mkdir() + with open(rep_dir / "preset.yml", "w") as f: + yaml.dump(rep_data, f) + (rep_dir / "templates").mkdir() + (rep_dir / "templates" / "spec-template.md").write_text("# Replaced content\n") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - manager = PresetManager(project_dir) - outer_src = _make_wrap_preset_dir(temp_dir, "preset-outer", "speckit.specify", "OUTER-PRE", "OUTER-POST") - inner_src = _make_wrap_preset_dir(temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST") - manager.install_from_directory(outer_src, "0.1.0", priority=5) - manager.install_from_directory(inner_src, "0.1.0", priority=10) - manager.remove("preset-outer") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + manager.install_from_directory(rep_dir, "0.1.5", priority=1) - written = (agent_dir / "speckit.specify.md").read_text() - # Only inner wrap remains — should be: INNER-PRE + CORE + INNER-POST, no OUTER - assert "INNER-PRE" in written - assert "CORE" in written - assert "INNER-POST" in written - assert "OUTER-PRE" not in written - assert "OUTER-POST" not in written - - def test_wrap_aliases_are_replayed_and_removed(self, project_dir, temp_dir): - """Replay preserves wrap aliases across install/remove lifecycle changes.""" - from specify_cli.agents import CommandRegistrar - import copy + resolver = PresetResolver(project_dir) + content = resolver.resolve_content("spec-template") + assert content == "# Replaced content\n" - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], - } - try: - manager = PresetManager(project_dir) - outer_src = _make_wrap_preset_dir( - temp_dir, - "preset-outer", - "speckit.specify", - "OUTER-PRE", - "OUTER-POST", - aliases=["speckit.alias"], - ) - inner_src = _make_wrap_preset_dir( - temp_dir, "preset-inner", "speckit.specify", "INNER-PRE", "INNER-POST" - ) - manager.install_from_directory(outer_src, "0.1.0", priority=5) - manager.install_from_directory(inner_src, "0.1.0", priority=10) - - alias_file = agent_dir / "speckit.alias.md" - written = alias_file.read_text() - assert "OUTER-PRE" in written - assert "INNER-PRE" in written - assert "INNER-POST" in written - assert "OUTER-POST" in written - - manager.remove("preset-inner") - written = alias_file.read_text() - assert "OUTER-PRE" in written - assert "OUTER-POST" in written - assert "INNER-PRE" not in written - assert "INNER-POST" not in written - - manager.remove("preset-outer") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) +class TestCollectAllLayers: + """Test PresetResolver.collect_all_layers() method.""" - assert not (agent_dir / "speckit.alias.md").exists() + def test_single_core_layer(self, project_dir): + """Test collecting layers with only core template.""" + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 1 + assert layers[0]["source"] == "core" + assert layers[0]["strategy"] == "replace" - def test_remove_last_wrap_preset_deletes_agent_file(self, project_dir, temp_dir): - """Removing the only wrap preset deletes the agent command file.""" - from specify_cli.agents import CommandRegistrar - import copy + def test_layers_include_presets(self, project_dir, temp_dir, valid_pack_data): + """Test that layers include installed preset.""" + manager = PresetManager(project_dir) + pack_dir = _create_pack(temp_dir, valid_pack_data, "test-pack", + "# From Pack\n") + manager.install_from_directory(pack_dir, "0.1.5") - core_dir = project_dir / ".specify" / "templates" / "commands" - core_dir.mkdir(parents=True, exist_ok=True) - (core_dir / "specify.md").write_text("---\ndescription: core\n---\n\nCORE\n") + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 2 + # Highest priority first + assert "test-pack" in layers[0]["source"] + assert layers[1]["source"] == "core" + + def test_layers_order_matches_priority(self, project_dir, temp_dir, valid_pack_data): + """Test that layers are ordered by priority (highest first).""" + manager = PresetManager(project_dir) + for pid, prio in [("pack-lo", 10), ("pack-hi", 1)]: + d = {**valid_pack_data} + d["preset"] = {**valid_pack_data["preset"], "id": pid, "name": pid} + p = temp_dir / pid + p.mkdir() + with open(p / "preset.yml", 'w') as f: + yaml.dump(d, f) + (p / "templates").mkdir() + (p / "templates" / "spec-template.md").write_text(f"# {pid}\n") + manager.install_from_directory(p, "0.1.5", priority=prio) - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + assert len(layers) == 3 # pack-hi, pack-lo, core + assert "pack-hi" in layers[0]["source"] + assert "pack-lo" in layers[1]["source"] + assert layers[2]["source"] == "core" + + def test_layers_read_strategy_from_manifest(self, project_dir, temp_dir, valid_pack_data): + """Test that layers read strategy from preset manifest.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": "strat-pack", "name": "Strat"} + pack_data["provides"] = { + "templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + "strategy": "append", + }] } - try: - manager = PresetManager(project_dir) - src = _make_wrap_preset_dir(temp_dir, "preset-only", "speckit.specify", "PRE", "POST") - manager.install_from_directory(src, "0.1.0", priority=10) - assert (agent_dir / "speckit.specify.md").exists() - manager.remove("preset-only") - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + pack_dir = temp_dir / "strat-pack" + pack_dir.mkdir() + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text("## Footer\n") - assert not (agent_dir / "speckit.specify.md").exists() - - def test_remove_keeps_registry_entry_when_directory_delete_fails(self, project_dir, monkeypatch): - """A failed preset directory delete must not leave files untracked by the registry.""" manager = PresetManager(project_dir) - pack_dir = manager.presets_dir / "preset-a" - pack_dir.mkdir(parents=True) - manager.registry.add("preset-a", { - "version": "1.0.0", "source": "local", "enabled": True, - "priority": 10, "manifest_hash": "x", - "registered_commands": {}, "registered_skills": [], - "wrap_commands": [], - }) + manager.install_from_directory(pack_dir, "0.1.5") - def fail_rmtree(_path): - raise OSError("locked") + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("spec-template") + # Preset layer should have strategy=append + assert layers[0]["strategy"] == "append" + # Core layer should be replace + assert layers[1]["strategy"] == "replace" - monkeypatch.setattr(shutil, "rmtree", fail_rmtree) - with pytest.raises(OSError, match="locked"): - manager.remove("preset-a") +class TestRemoveReconciliation: + """Test that removing a preset re-registers the next layer's command.""" - assert manager.registry.is_installed("preset-a") - assert pack_dir.exists() + def test_remove_restores_lower_priority_command( + self, project_dir, temp_dir, valid_pack_data + ): + """After removing the top-priority preset, the next preset's command + should be re-registered in agent directories.""" + manager = PresetManager(project_dir) - def test_non_wrap_commands_unaffected_by_wrap_lifecycle(self, project_dir, temp_dir): - """wrap_commands is empty for a preset with no strategy:wrap commands.""" - from specify_cli.agents import CommandRegistrar - import copy - import yaml as _yaml + # Create a gemini commands dir so reconciliation writes there + gemini_dir = project_dir / ".gemini" / "commands" + gemini_dir.mkdir(parents=True) - # Create a preset with a non-wrap command - preset_dir = temp_dir / "non-wrap-preset" - cmd_dir = preset_dir / "commands" - cmd_dir.mkdir(parents=True) - manifest = { - "schema_version": "1.0", - "preset": { - "id": "non-wrap-preset", "name": "Non-wrap", "version": "1.0.0", - "description": "no wrap", "author": "test", - "repository": "https://example.com", "license": "MIT", - }, - "requires": {"speckit_version": ">=0.1.0"}, - "provides": {"templates": [ - {"type": "command", "name": "speckit.specify", - "file": "commands/speckit.specify.md", "description": "override"}, - ]}, - "tags": [], + # Install a low-priority preset with a command + lo_data = {**valid_pack_data} + lo_data["preset"] = { + **valid_pack_data["preset"], + "id": "lo-preset", + "name": "Lo", } - (preset_dir / "preset.yml").write_text(_yaml.dump(manifest)) - (cmd_dir / "speckit.specify.md").write_text( - "---\ndescription: plain override\n---\n\nplain body\n" + lo_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + lo_dir = temp_dir / "lo-preset" + lo_dir.mkdir() + with open(lo_dir / "preset.yml", "w") as f: + yaml.dump(lo_data, f) + (lo_dir / "commands").mkdir() + (lo_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: lo\n---\nLo content\n" ) - - registrar = CommandRegistrar() - original = copy.deepcopy(registrar.AGENT_CONFIGS) - agent_dir = project_dir / ".claude" / "commands" - agent_dir.mkdir(parents=True, exist_ok=True) - registrar.AGENT_CONFIGS["test-agent"] = { - "dir": str(agent_dir.relative_to(project_dir)), - "format": "markdown", "args": "$ARGUMENTS", - "extension": ".md", "strip_frontmatter_keys": [], + manager.install_from_directory(lo_dir, "0.1.5", priority=10) + + # Install a high-priority preset overriding the same command + hi_data = {**valid_pack_data} + hi_data["preset"] = { + **valid_pack_data["preset"], + "id": "hi-preset", + "name": "Hi", } - try: - manager = PresetManager(project_dir) - manager.install_from_directory(preset_dir, "0.1.0", priority=10) - finally: - CommandRegistrar.AGENT_CONFIGS.clear() - CommandRegistrar.AGENT_CONFIGS.update(original) + hi_data["provides"] = { + "templates": [{ + "type": "command", + "name": "speckit.specify", + "file": "commands/speckit.specify.md", + }] + } + hi_dir = temp_dir / "hi-preset" + hi_dir.mkdir() + with open(hi_dir / "preset.yml", "w") as f: + yaml.dump(hi_data, f) + (hi_dir / "commands").mkdir() + (hi_dir / "commands" / "speckit.specify.md").write_text( + "---\ndescription: hi\n---\nHi content\n" + ) + manager.install_from_directory(hi_dir, "0.1.5", priority=1) - meta = manager.registry.get("non-wrap-preset") - assert meta.get("wrap_commands", []) == [] - written = (agent_dir / "speckit.specify.md").read_text() - assert "plain body" in written + # Verify the hi-preset's content is active in agent dir + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should exist in gemini dir" + assert "Hi content" in cmd_files[0].read_text() + + # Remove the high-priority preset + manager.remove("hi-preset") + + # The low-priority preset's command should now be in the resolution stack + resolver = PresetResolver(project_dir) + layers = resolver.collect_all_layers("speckit.specify", "command") + assert len(layers) >= 1 + assert "lo-preset" in layers[0]["source"] + + # Verify on-disk agent command file switched to lo-preset content + cmd_files = list(gemini_dir.glob("*specify*")) + assert cmd_files, "Command file should still exist after removal" + assert "Lo content" in cmd_files[0].read_text() + + +def _create_pack(temp_dir, valid_pack_data, pack_id, content, + strategy="replace", template_type="template", + template_name="spec-template"): + """Helper to create a preset pack directory.""" + pack_data = {**valid_pack_data} + pack_data["preset"] = {**valid_pack_data["preset"], "id": pack_id, "name": pack_id} + + tmpl_entry = { + "type": template_type, + "name": template_name, + } + if template_type == "script": + tmpl_entry["file"] = f"scripts/{template_name}.sh" + elif template_type == "command": + tmpl_entry["file"] = f"commands/{template_name}.md" + else: + tmpl_entry["file"] = f"templates/{template_name}.md" + if strategy != "replace": + tmpl_entry["strategy"] = strategy + pack_data["provides"] = {"templates": [tmpl_entry]} + + pack_dir = temp_dir / pack_id + pack_dir.mkdir(exist_ok=True) + with open(pack_dir / "preset.yml", 'w') as f: + yaml.dump(pack_data, f) + + if template_type == "script": + subdir = pack_dir / "scripts" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.sh").write_text(content) + elif template_type == "command": + subdir = pack_dir / "commands" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + else: + subdir = pack_dir / "templates" + subdir.mkdir(exist_ok=True) + (subdir / f"{template_name}.md").write_text(content) + + return pack_dir From 89fc554ce57f02288b1ad9ad660f551ddb3cbe45 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:12:09 -0500 Subject: [PATCH 104/184] chore: release 0.8.0, begin 0.8.1.dev0 development (#2333) * chore: bump version to 0.8.0 * chore: begin 0.8.1.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd75c336aa..64ee3081d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ +## [0.8.0] - 2026-04-23 + +### Changed + +- feat(presets): Composition strategies (prepend, append, wrap) for templates, commands, and scripts (#2133) +- feat(copilot): support `--integration-options="--skills"` for skills-based scaffolding (#2324) +- docs(install): add pipx as alternative installation method (#2288) +- Add Memory MD community extension (#2327) +- Update version-guard to v1.2.0 (#2321) +- fix: `--force` now overwrites shared infra files during init and upgrade (#2320) +- chore: release 0.7.5, begin 0.7.6.dev0 development (#2322) + ## [0.7.5] - 2026-04-22 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 6b76d46f99..f505f89456 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.7.6.dev0" +version = "0.8.1.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 5a52b7623e5138935a1f59ba10d6e985d5e8c516 Mon Sep 17 00:00:00 2001 From: adaumann <94932945+adaumann@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:24:29 +0200 Subject: [PATCH 105/184] feat: Preset screenwriting (#2332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update preset-fiction-book-writing to community catalog - Preset ID: fiction-book-writing - Version: 1.5.0 - Author: Andreas Daumann - Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. V1.5.0: Support interactive, audiobooks, series, workflow corrections * Add screenwriting preset to community catalog - Preset ID: screenwriting - Version: 1.0.0 - Author: Andreas Daumann - Description: Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Speckit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 20 +++++++++++++++++- presets/catalog.community.json | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 88332922d5..823d49f8a7 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,25 @@ To submit your own extension, see the [Extension Publishing Guide](extensions/EX ## 🎨 Community Presets -Community-contributed presets that customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. See the full list on the [Community Presets](https://github.github.io/spec-kit/community/presets.html) page. +> [!NOTE] +> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. + +The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](presets/catalog.community.json): + +| Preset | Purpose | Provides | Requires | URL | +|--------|---------|----------|----------|-----| +| 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) | +| 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) | +| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations): features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes, author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay. | 22 templates, 27 commands, 1 script | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | +| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | +| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), stage plays and tutorials/educational. Adapts the Speckit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands | 1 script | [spec-kit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) | +| 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) | + +This table highlights a curated subset of community presets. For the complete list of currently available presets, see [`presets/catalog.community.json`](presets/catalog.community.json). +To build and publish your own preset, see the [Presets Publishing Guide](presets/PUBLISHING.md). ## 🚶 Community Walkthroughs diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 0e0194b27d..c9c23637bd 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -194,6 +194,44 @@ "experimental" ] }, + "screenwriting": { + "name": "Screenwriting", + "id": "screenwriting", + "version": "1.0.0", + "description": "Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.", + "author": "Andreas Daumann", + "repository": "https://github.com/adaumann/speckit-preset-screenwriting", + "download_url": "https://github.com/adaumann/speckit-preset-screenwriting/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/adaumann/speckit-preset-screenwriting", + "documentation": "https://github.com/adaumann/speckit-preset-screenwriting/blob/main/screenwriting/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.5.0" + }, + "provides": { + "templates": 26, + "commands": 32, + "scripts": 1 + }, + "tags": [ + "writing", + "screenplay", + "scriptwriting", + "film", + "tv", + "fountain", + "fountain-format", + "beat-sheet", + "teleplay", + "drama", + "comedy", + "storytelling", + "tutorial", + "education" + ], + "created_at": "2026-04-23T08:00:00Z", + "updated_at": "2026-04-23T08:00:00Z" + }, "toc-navigation": { "name": "Table of Contents Navigation", "id": "toc-navigation", From 6bf4ebbe333701cd7a75b48f5404553d93a830c0 Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Thu, 23 Apr 2026 20:32:30 +0100 Subject: [PATCH 106/184] feat: register jira preset in community catalog (#2224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: register jira preset in community catalog Adds luno/spec-kit-preset-jira — overrides speckit.taskstoissues to create Jira issues instead of GitHub Issues. See #2223 for context on why this is a preset rather than an extension. Co-Authored-By: Claude Opus 4.6 * fix: use immutable tag URL and sort jira preset alphabetically - Change download_url from heads/main to refs/tags/v1.0.0 for reproducible installs - Move jira entry to correct alphabetical position in presets object Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Ed Harrod <1381991+echarrod@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- presets/catalog.community.json | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/presets/catalog.community.json b/presets/catalog.community.json index c9c23637bd..caf28e5041 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-13T00:00:00Z", + "updated_at": "2026-04-15T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "aide-in-place": { @@ -141,7 +141,34 @@ ], "created_at": "2026-04-09T08:00:00Z", "updated_at": "2026-04-19T08:00:00Z" - }, + }, + "jira": { + "name": "Jira Issue Tracking", + "id": "jira", + "version": "1.0.0", + "description": "Overrides speckit.taskstoissues to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools.", + "author": "luno", + "repository": "https://github.com/luno/spec-kit-preset-jira", + "download_url": "https://github.com/luno/spec-kit-preset-jira/archive/refs/tags/v1.0.0.zip", + "homepage": "https://github.com/luno/spec-kit-preset-jira", + "documentation": "https://github.com/luno/spec-kit-preset-jira/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 0, + "commands": 1 + }, + "tags": [ + "jira", + "atlassian", + "issue-tracking", + "preset" + ], + "created_at": "2026-04-15T00:00:00Z", + "updated_at": "2026-04-15T00:00:00Z" + }, "multi-repo-branching": { "name": "Multi-Repo Branching", "id": "multi-repo-branching", From 13d88d22a64b77aa6ee481d079c35d74fa10bacb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:13:36 -0500 Subject: [PATCH 107/184] fix: replace xargs trim with sed to handle quotes in descriptions (#2351) xargs re-parses stdin as shell tokens, causing 'unterminated quote' errors when feature descriptions contain apostrophes, double quotes, or backslashes. Replace with sed-based whitespace trim that preserves input verbatim. Add regression tests for special characters in descriptions (core and extension scripts), plus a negative test for whitespace-only input. Fixes #2339 --- .../git/scripts/bash/create-new-feature.sh | 2 +- scripts/bash/create-new-feature.sh | 2 +- tests/test_timestamp_branches.py | 64 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/extensions/git/scripts/bash/create-new-feature.sh b/extensions/git/scripts/bash/create-new-feature.sh index 286aaf7634..f7aa31610e 100755 --- a/extensions/git/scripts/bash/create-new-feature.sh +++ b/extensions/git/scripts/bash/create-new-feature.sh @@ -95,7 +95,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then fi # Trim whitespace and validate description is not empty -FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') if [ -z "$FEATURE_DESCRIPTION" ]; then echo "Error: Feature description cannot be empty or contain only whitespace" >&2 exit 1 diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 1879647026..c3537704f6 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -84,7 +84,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then fi # Trim whitespace and validate description is not empty (e.g., user passed only whitespace) -FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs) +FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') if [ -z "$FEATURE_DESCRIPTION" ]; then echo "Error: Feature description cannot be empty or contain only whitespace" >&2 exit 1 diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 39228d9455..c99f675081 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -1257,3 +1257,67 @@ def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path): break else: pytest.fail("FEATURE_DIR not found in PowerShell output") + + +# ── Description Quoting Tests (issue #2339) ────────────────────────────────── + + +@requires_bash +class TestDescriptionQuoting: + """Descriptions with quotes, apostrophes, and backslashes must not break the script. + + Regression tests for https://github.com/github/spec-kit/issues/2339 + """ + + @pytest.mark.parametrize( + "description", + [ + "Add user's profile page", + "Fix the \"login\" bug", + "Handle path\\with\\backslashes", + "It's a \"complex\" feature\\here", + ], + ids=["apostrophe", "double-quotes", "backslashes", "mixed"], + ) + def test_core_script_handles_special_chars(self, git_repo: Path, description: str): + """Core create-new-feature.sh succeeds with special characters in description.""" + result = run_script(git_repo, "--dry-run", "--short-name", "feat", description) + assert result.returncode == 0, ( + f"Script failed for description {description!r}: {result.stderr}" + ) + + @pytest.mark.parametrize( + "description", + [ + "Add user's profile page", + "Fix the \"login\" bug", + "Handle path\\with\\backslashes", + "It's a \"complex\" feature\\here", + ], + ids=["apostrophe", "double-quotes", "backslashes", "mixed"], + ) + def test_ext_script_handles_special_chars(self, ext_git_repo: Path, description: str): + """Extension create-new-feature.sh succeeds with special characters in description.""" + script = ( + ext_git_repo / ".specify" / "extensions" / "git" / "scripts" / "bash" / "create-new-feature.sh" + ) + result = subprocess.run( + ["bash", str(script), "--dry-run", "--short-name", "feat", description], + cwd=ext_git_repo, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"Script failed for description {description!r}: {result.stderr}" + ) + + def test_whitespace_only_still_rejected(self, git_repo: Path): + """Whitespace-only descriptions must still be rejected after trimming.""" + result = run_script(git_repo, "--dry-run", "--short-name", "feat", " ") + assert result.returncode != 0 + assert "empty" in result.stderr.lower() or "whitespace" in result.stderr.lower() + + def test_plain_description_still_works(self, git_repo: Path): + """Plain description without special characters continues to work.""" + result = run_script(git_repo, "--dry-run", "--short-name", "feat", "Add login feature") + assert result.returncode == 0, result.stderr From 7f708b9e6f629b81fa98fefb0e0297edea955396 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:36:23 -0500 Subject: [PATCH 108/184] chore(deps): bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#2345) Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 8.0.0 to 8.1.0. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/cec208311dfd045dd5311c1add060b2062131d57...08807647e7069bb48b6ef5acd8ec9567f424441b) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 8.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44b0269887..7354dd8e28 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Set up Python uses: actions/setup-python@v6 @@ -37,7 +37,7 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 From 6413414907d892c62031e863c237afc6de5bbdb2 Mon Sep 17 00:00:00 2001 From: Valentyn Date: Fri, 24 Apr 2026 17:48:50 +0300 Subject: [PATCH 109/184] Update product-forge to v1.5.1 in community catalog (#2352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update product-forge to v1.5.0 in community catalog - Extension ID: product-forge - Version: 1.1.1 → 1.5.0 - Author: VaiYav - Description: updated to reflect v1.5 features (portfolio, lite mode, monorepo, optional V-Model) - Commands: 10 → 29 - Tags: refreshed to reflect current surface area - download_url pinned to v1.5.0 release tag - updated_at bumped to 2026-04-24 Release: https://github.com/VaiYav/speckit-product-forge/releases/tag/v1.5.0 * Bump product-forge to v1.5.1 (docs patch) Follow-up to v1.5.0 that surfaces the optional V-Model dependency (leocamello/spec-kit-v-model ≥ 0.5.0) in README Requirements, config-template.yml, and docs/config.md. Docs-only patch — no behavioural change. Release: https://github.com/VaiYav/speckit-product-forge/releases/tag/v1.5.1 --- README.md | 2 +- extensions/catalog.community.json | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 823d49f8a7..120291a918 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ 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) | | 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) | -| Product Forge | Full product lifecycle: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) | +| 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) | | 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) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ecfcbef2c7..469e3ddf9b 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-23T00:00:00Z", + "updated_at": "2026-04-24T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1424,10 +1424,10 @@ "product-forge": { "name": "Product Forge", "id": "product-forge", - "description": "Full product lifecycle: research → product spec → SpecKit → implement → verify → test", + "description": "Full product lifecycle from research to release — portfolio, lite mode, monorepo, optional V-Model", "author": "VaiYav", - "version": "1.1.1", - "download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.1.1.zip", + "version": "1.5.1", + "download_url": "https://github.com/VaiYav/speckit-product-forge/archive/refs/tags/v1.5.1.zip", "repository": "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", @@ -1437,21 +1437,21 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 10, + "commands": 29, "hooks": 0 }, "tags": [ "process", - "research", - "product-spec", "lifecycle", - "testing" + "monorepo", + "v-model", + "portfolio" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-03-28T00:00:00Z", - "updated_at": "2026-03-28T00:00:00Z" + "updated_at": "2026-04-24T15:52:00Z" }, "qa": { "name": "QA Testing Extension", From 52c0a5f88fa9f5902e82ea406f1745f94d54d85d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:04:14 -0500 Subject: [PATCH 110/184] fix: resolve command references per integration type (dot vs hyphen) (#2354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve command references per integration type (dot vs hyphen) Replace hardcoded /speckit. references in templates with __SPECKIT_COMMAND___ placeholders that are resolved at setup time based on the integration type: - Markdown/TOML/YAML agents: separator='.' → /speckit.plan - Skills agents: separator='-' → /speckit-plan Changes: - Add resolve_command_refs() static method to IntegrationBase - Add invoke_separator class attribute (. for base, - for skills) - Wire into process_template() as step 8 - Update _install_shared_infra() to process page templates - Replace /speckit.* in 5 command templates and 3 page templates - Add unit tests for resolve_command_refs (positive + negative) - Add integration tests verifying on-disk content for all agents - Add end-to-end CLI tests for Claude (skills) and Copilot (markdown) Fixes #2347 * review: use effective_invoke_separator() for Copilot skills mode Address PR review feedback: instead of bleeding _skills_mode knowledge into the CLI layer, add effective_invoke_separator() method to IntegrationBase that accepts parsed_options. CopilotIntegration overrides it to return "-" when skills mode is requested. The CLI layer simply asks the integration for its separator — no hasattr or _skills_mode coupling. Also adds tests for the new method on both base and Copilot, plus an end-to-end test for 'specify init --integration copilot --integration-options --skills' verifying page templates get hyphen refs. * fix: build_command_invocation preserves full suffix for extension commands Previously rsplit('.', 1)[-1] on 'speckit.git.commit' yielded just 'commit', producing /speckit.commit instead of /speckit.git.commit (or /speckit-git-commit for skills). Fix: strip only the 'speckit.' prefix when present, then join remaining segments with the appropriate separator. Updated in IntegrationBase, SkillsIntegration, and CopilotIntegration. Added tests for extension commands in build_command_invocation across all three. * fix: Copilot dispatch_command() preserves full extension command suffix dispatch_command() had the same rsplit('.', 1)[-1] bug as build_command_invocation() — speckit.git.commit would dispatch as /speckit-commit instead of /speckit-git-commit in skills mode, or --agent speckit.commit instead of speckit.git.commit in default mode. --- src/specify_cli/__init__.py | 54 +++++--- src/specify_cli/integrations/base.py | 54 +++++++- .../integrations/copilot/__init__.py | 22 ++- templates/checklist-template.md | 4 +- templates/commands/analyze.md | 8 +- templates/commands/checklist.md | 2 +- templates/commands/clarify.md | 8 +- templates/commands/implement.md | 2 +- templates/commands/specify.md | 10 +- templates/plan-template.md | 14 +- templates/tasks-template.md | 2 +- tests/integrations/test_base.py | 128 +++++++++++++++++ tests/integrations/test_cli.py | 130 ++++++++++++++++++ .../test_integration_base_markdown.py | 1 + .../test_integration_base_skills.py | 16 +++ .../test_integration_base_toml.py | 1 + .../test_integration_base_yaml.py | 1 + tests/integrations/test_integration_claude.py | 2 + .../integrations/test_integration_copilot.py | 28 ++++ tests/integrations/test_integration_forge.py | 1 + .../integrations/test_integration_generic.py | 1 + 21 files changed, 434 insertions(+), 55 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 77611128b5..1c3e63ec03 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -723,6 +723,7 @@ def _install_shared_infra( script_type: str, tracker: StepTracker | None = None, force: bool = False, + invoke_separator: str = ".", ) -> bool: """Install shared infrastructure files into *project_path*. @@ -730,12 +731,17 @@ def _install_shared_infra( bundled core_pack or source checkout. Tracks all installed files in ``speckit.manifest.json``. + Page templates are processed to resolve ``__SPECKIT_COMMAND___`` + placeholders using *invoke_separator* (``"."`` for markdown agents, + ``"-"`` for skills agents). + When *force* is ``True``, existing files are overwritten with the latest bundled versions. When ``False`` (default), only missing files are added and existing ones are skipped. Returns ``True`` on success. """ + from .integrations.base import IntegrationBase from .integrations.manifest import IntegrationManifest core = _locate_core_pack() @@ -786,7 +792,11 @@ def _install_shared_infra( if dst.exists() and not force: skipped_files.append(str(dst.relative_to(project_path))) else: - shutil.copy2(f, dst) + content = f.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs( + content, invoke_separator + ) + dst.write_text(content, encoding="utf-8") rel = dst.relative_to(project_path).as_posix() manifest.record_existing(rel) @@ -1295,7 +1305,7 @@ def init( # Install shared infrastructure (scripts, templates) tracker.start("shared-infra") - _install_shared_infra(project_path, selected_script, tracker=tracker, force=force) + _install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options)) tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_constitution_from_template(project_path, tracker=tracker) @@ -2072,9 +2082,16 @@ def integration_install( 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. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + # Ensure shared infrastructure is present (safe to run unconditionally; # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script) + _install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options)) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2082,11 +2099,6 @@ def integration_install( integration.key, project_root, version=get_speckit_version() ) - # Build parsed options from --integration-options - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(integration, integration_options) - try: integration.setup( project_root, manifest, @@ -2356,9 +2368,16 @@ def integration_switch( opts.pop("context_file", None) save_init_options(project_root, opts) + # Build parsed options from --integration-options so the integration + # can determine its effective invoke separator before shared infra + # is installed. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(target_integration, integration_options) + # Ensure shared infrastructure is present (safe to run unconditionally; # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script) + _install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options)) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2368,10 +2387,6 @@ def integration_switch( target_integration.key, project_root, version=get_speckit_version() ) - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(target_integration, integration_options) - try: target_integration.setup( project_root, manifest, @@ -2465,8 +2480,15 @@ def integration_upgrade( 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. + parsed_options: dict[str, Any] | None = None + if integration_options: + parsed_options = _parse_integration_options(integration, integration_options) + # Ensure shared infrastructure is up to date; --force overwrites existing files. - _install_shared_infra(project_root, selected_script, force=force) + _install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options)) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2474,10 +2496,6 @@ def integration_upgrade( console.print(f"Upgrading integration: [cyan]{key}[/cyan]") new_manifest = IntegrationManifest(key, project_root, version=get_speckit_version()) - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(integration, integration_options) - try: integration.setup( project_root, diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index a3d8a42aa2..f3b74b0c05 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -84,6 +84,9 @@ class IntegrationBase(ABC): context_file: str | None = None """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" + invoke_separator: str = "." + """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" + # -- Markers for managed context section ------------------------------ CONTEXT_MARKER_START = "" @@ -96,6 +99,18 @@ def options(cls) -> list[IntegrationOption]: """Return options this integration accepts. Default: none.""" return [] + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return the invoke separator for the given options. + + Subclasses whose separator depends on runtime options (e.g. + Copilot in ``--skills`` mode) should override this method. + The default implementation ignores *parsed_options* and returns + the class-level ``invoke_separator``. + """ + return self.invoke_separator + def build_exec_args( self, prompt: str, @@ -122,11 +137,12 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: agents or ``"/speckit-specify my-feature"`` for skills agents. *command_name* may be a full dotted name like - ``"speckit.specify"`` or a bare stem like ``"specify"``. + ``"speckit.specify"``, an extension command like + ``"speckit.git.commit"``, or a bare stem like ``"specify"``. """ stem = command_name - if "." in stem: - stem = stem.rsplit(".", 1)[-1] + if stem.startswith("speckit."): + stem = stem[len("speckit."):] invocation = f"/speckit.{stem}" if args: @@ -597,6 +613,24 @@ def remove_context_section(self, project_root: Path) -> bool: return True + @staticmethod + def resolve_command_refs(content: str, separator: str = ".") -> str: + """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. + + Each placeholder encodes a command name in upper-case with + underscores (e.g. ``__SPECKIT_COMMAND_PLAN__``, + ``__SPECKIT_COMMAND_GIT_COMMIT__``). The replacement uses + *separator* to join the segments: + + * ``separator="."`` → ``/speckit.plan``, ``/speckit.git.commit`` + * ``separator="-"`` → ``/speckit-plan``, ``/speckit-git-commit`` + """ + return re.sub( + r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__", + lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator), + content, + ) + @staticmethod def process_template( content: str, @@ -604,6 +638,7 @@ def process_template( script_type: str, arg_placeholder: str = "$ARGUMENTS", context_file: str = "", + invoke_separator: str = ".", ) -> str: """Process a raw command template into agent-ready content. @@ -615,6 +650,7 @@ def process_template( 5. Replace ``__AGENT__`` with *agent_name* 6. Replace ``__CONTEXT_FILE__`` with *context_file* 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. + 8. Replace ``__SPECKIT_COMMAND___`` with invocation strings """ # 1. Extract script command from frontmatter script_command = "" @@ -684,6 +720,9 @@ def process_template( content = CommandRegistrar.rewrite_project_relative_paths(content) + # 8. Replace __SPECKIT_COMMAND___ with invocation strings + content = IntegrationBase.resolve_command_refs(content, invoke_separator) + return content def setup( @@ -1274,6 +1313,8 @@ class SkillsIntegration(IntegrationBase): ``speckit-/SKILL.md`` file with skills-oriented frontmatter. """ + invoke_separator = "-" + def build_exec_args( self, prompt: str, @@ -1311,10 +1352,10 @@ def skills_dest(self, project_root: Path) -> Path: def build_command_invocation(self, command_name: str, args: str = "") -> str: """Skills use ``/speckit-`` (hyphenated directory name).""" stem = command_name - if "." in stem: - stem = stem.rsplit(".", 1)[-1] + if stem.startswith("speckit."): + stem = stem[len("speckit."):] - invocation = f"/speckit-{stem}" + invocation = "/speckit-" + stem.replace(".", "-") if args: invocation = f"{invocation} {args}" return invocation @@ -1395,6 +1436,7 @@ def setup( processed_body = self.process_template( raw, self.key, script_type, arg_placeholder, context_file=self.context_file or "", + invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. # Preserve leading whitespace in the body to match release ZIP diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 5c4d0e5410..c7456ce7f0 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -103,6 +103,16 @@ class CopilotIntegration(IntegrationBase): # Mutable flag set by setup() — indicates the active scaffolding mode. _skills_mode: bool = False + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return ``"-"`` when skills mode is requested, ``"."`` otherwise.""" + if parsed_options and parsed_options.get("skills"): + return "-" + if self._skills_mode: + return "-" + return self.invoke_separator + @classmethod def options(cls) -> list[IntegrationOption]: return [ @@ -145,9 +155,9 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str: """ if self._skills_mode: stem = command_name - if "." in stem: - stem = stem.rsplit(".", 1)[-1] - invocation = f"/speckit-{stem}" + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + invocation = "/speckit-" + stem.replace(".", "-") if args: invocation = f"{invocation} {args}" return invocation @@ -175,8 +185,8 @@ def dispatch_command( import subprocess stem = command_name - if "." in stem: - stem = stem.rsplit(".", 1)[-1] + if stem.startswith("speckit."): + stem = stem[len("speckit."):] # Detect skills mode from project layout when not set via setup() skills_mode = self._skills_mode @@ -189,7 +199,7 @@ def dispatch_command( ) if skills_mode: - prompt = f"/speckit-{stem}" + prompt = "/speckit-" + stem.replace(".", "-") if args: prompt = f"{prompt} {args}" else: diff --git a/templates/checklist-template.md b/templates/checklist-template.md index 806657da09..9752c130ec 100644 --- a/templates/checklist-template.md +++ b/templates/checklist-template.md @@ -4,13 +4,13 @@ **Created**: [DATE] **Feature**: [Link to spec.md or relevant documentation] -**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. +**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements. +## [0.8.1] - 2026-04-24 + +### Changed + +- fix(plan): use .specify/feature.json to allow /speckit.plan on custom git branches (#2305) (#2349) +- feat(vibe): migrate to SkillsIntegration from the old prompts-based MarkdownIntegration (#2336) +- docs: move community presets table to docs site, add missing entries (#2341) +- docs(presets): add lean preset README and enrich catalog metadata (#2340) +- fix: resolve command references per integration type (dot vs hyphen) (#2354) +- Update product-forge to v1.5.1 in community catalog (#2352) +- chore(deps): bump astral-sh/setup-uv from 8.0.0 to 8.1.0 (#2345) +- fix: replace xargs trim with sed to handle quotes in descriptions (#2351) +- feat: register jira preset in community catalog (#2224) +- feat: Preset screenwriting (#2332) +- chore: release 0.8.0, begin 0.8.1.dev0 development (#2333) + ## [0.8.0] - 2026-04-23 ### Changed diff --git a/pyproject.toml b/pyproject.toml index f505f89456..23a0344faa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.8.1.dev0" +version = "0.8.2.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From aad7b161883d5d6c6e8d7ba4c75154a0d6224e87 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 24 Apr 2026 23:11:39 +0500 Subject: [PATCH 116/184] Add Spec Orchestrator extension to community catalog (#2350) --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b877d3744a..3aa320fc73 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) | | Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) | | Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) | +| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) | | Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) | | Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 469e3ddf9b..b158aeb4b6 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-24T00:00:00Z", + "updated_at": "2026-04-24T14:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1327,6 +1327,38 @@ "created_at": "2026-04-03T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z" }, + "orchestrator": { + "name": "Spec Orchestrator", + "id": "orchestrator", + "description": "Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-orchestrator", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-orchestrator", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-orchestrator/releases", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "orchestration", + "multi-feature", + "coordination", + "workflow", + "parallel" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-24T14:00:00Z", + "updated_at": "2026-04-24T14:00:00Z" + }, "plan-review-gate": { "name": "Plan Review Gate", "id": "plan-review-gate", From 03f3024c66aca79d21dc7775a76bc0d3282bbb63 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:54:40 -0500 Subject: [PATCH 117/184] feat(init): deprecate --no-git flag, gate deprecations at v0.10.0 (#2357) * feat(init): deprecate --no-git flag, gate deprecations at v0.10.0 - Add deprecation warning when --no-git is used on specify init - Update --ai deprecation gate from 1.0.0 to 0.10.0 - Update test expectation for the new version gate Closes #2167 * fix: address PR review feedback - Update --no-git deprecation message to reference existing 'specify extension' commands instead of non-existent --extension flag - Add test_no_git_emits_deprecation_warning CLI test * fix: strengthen --no-git deprecation test assertions Add assertions unique to the --no-git message ('will be removed', 'git extension will no longer be enabled by default') to prevent false positives from the --ai deprecation panel. --- src/specify_cli/__init__.py | 9 ++++++++- tests/integrations/test_cli.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 1c3e63ec03..618492f306 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -127,7 +127,7 @@ def _build_ai_deprecation_warning( ai_commands_dir=ai_commands_dir, ) return ( - "[bold]--ai[/bold] is deprecated and will no longer be available in version 1.0.0 or later.\n\n" + "[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n" f"Use [bold]{replacement}[/bold] instead." ) @@ -1088,6 +1088,13 @@ def init( 'use [bold]--integration generic --integration-options="--commands-dir "[/bold] instead.[/dim]' ) + if no_git: + console.print( + "[yellow]⚠️ --no-git is deprecated and will be removed in v0.10.0.[/yellow]\n" + "[yellow]The git extension will no longer be enabled by default " + "— use the [bold]specify extension[/bold] commands to install or enable the git extension if needed.[/yellow]" + ) + if project_name == ".": here = True project_name = None # Clear project_name to use existing validation logic diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 152c56813c..df48323ed2 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -112,7 +112,7 @@ def test_ai_emits_deprecation_warning_with_integration_replacement(self, tmp_pat assert "--ai" in normalized_output assert "deprecated" in normalized_output assert "no longer be available" in normalized_output - assert "1.0.0" in normalized_output + assert "0.10.0" in normalized_output assert "--integration copilot" in normalized_output assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps") assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() @@ -446,6 +446,33 @@ def test_no_git_skips_extension(self, tmp_path): ext_dir = project / ".specify" / "extensions" / "git" assert not ext_dir.exists(), "git extension should not be installed with --no-git" + def test_no_git_emits_deprecation_warning(self, tmp_path): + """Using --no-git emits a visible deprecation warning.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "no-git-warn" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "--no-git" in normalized_output + assert "deprecated" in normalized_output + assert "0.10.0" in normalized_output + assert "specify extension" in normalized_output + assert "will be removed" in normalized_output + assert "git extension will no longer be enabled by default" in normalized_output + def test_git_extension_commands_registered(self, tmp_path): """Git extension commands are registered with the agent during init.""" from typer.testing import CliRunner From ca51d739fbbb0511442c7513dd13969a4225f8bf Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:58:34 -0500 Subject: [PATCH 118/184] Update extensify to v1.1.0 in community catalog (#2337) --- extensions/catalog.community.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index b158aeb4b6..0c6e5cbfb3 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -657,18 +657,18 @@ "id": "extensify", "description": "Create and validate extensions and extension catalogs.", "author": "mnriem", - "version": "1.0.0", - "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.0.0/extensify.zip", + "version": "1.1.0", + "download_url": "https://github.com/mnriem/spec-kit-extensions/releases/download/extensify-v1.1.0/extensify.zip", "repository": "https://github.com/mnriem/spec-kit-extensions", "homepage": "https://github.com/mnriem/spec-kit-extensions", "documentation": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/README.md", "changelog": "https://github.com/mnriem/spec-kit-extensions/blob/main/extensify/CHANGELOG.md", "license": "MIT", "requires": { - "speckit_version": ">=0.2.0" + "speckit_version": ">=0.8.0" }, "provides": { - "commands": 4, + "commands": 5, "hooks": 0 }, "tags": [ @@ -681,7 +681,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-18T00:00:00Z", - "updated_at": "2026-03-18T00:00:00Z" + "updated_at": "2026-04-23T00:00:00Z" }, "fix-findings": { "name": "Fix Findings", From 232c19cb04f5ed26349295582a420fd0c71e0b21 Mon Sep 17 00:00:00 2001 From: Taylor Mulder <29389186+userhas404d@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:17:40 -0400 Subject: [PATCH 119/184] feat(extensions,presets): authenticate GitHub-hosted catalog and download requests with GITHUB_TOKEN/GH_TOKEN (#2331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(extensions,presets): authenticate GitHub-hosted catalog and download requests with GITHUB_TOKEN/GH_TOKEN Squashed from #2087 (original author: @anasseth). Adds GitHub-token authentication to extension and preset catalog fetching and ZIP downloads so private GitHub repos work when GITHUB_TOKEN/GH_TOKEN is set, while preventing credential leakage to non-GitHub hosts. - Introduces shared _github_http module with build_github_request() and open_github_url() helpers - Routes ExtensionCatalog and PresetCatalog network calls through GitHub-auth-aware opener - Adds comprehensive unit/integration tests for auth header behavior - Updates user docs for both extensions and presets Co-authored-by: anasseth <16745089+anasseth@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(auth): address review feedback from #2087 - Fix redirect handler to preserve Authorization on GitHub-to-GitHub redirects (e.g. github.com → codeload.github.com). The previous implementation relied on super().redirect_request() which strips auth on cross-host redirects, breaking private repo archive downloads. - Add codeload.github.com to documented host lists in both EXTENSION-USER-GUIDE.md and presets/README.md - Add redirect auth-preservation and auth-stripping tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(auth): use Bearer scheme instead of token for consistency Aligns with the rest of the codebase (e.g. __init__.py:1721) and GitHub's current API guidance. Updates all test assertions accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address second round of Copilot review feedback - Fix docstring to say Bearer instead of token (matches implementation) - Remove unused imports/fixtures from redirect tests (GITHUB_HOSTS, MagicMock, temp_dir, monkeypatch) - Replace __import__('io').BytesIO() with normal import io pattern in test_presets.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: anasseth <16745089+anasseth@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/EXTENSION-USER-GUIDE.md | 17 ++- presets/README.md | 22 ++- src/specify_cli/_github_http.py | 80 +++++++++++ src/specify_cli/extensions.py | 25 +++- src/specify_cli/presets.py | 29 ++-- tests/test_extensions.py | 209 +++++++++++++++++++++++++++++ tests/test_presets.py | 160 ++++++++++++++++++++++ 7 files changed, 522 insertions(+), 20 deletions(-) create mode 100644 src/specify_cli/_github_http.py diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 595985d955..77e321b9cf 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -423,7 +423,7 @@ In addition to extension-specific environment variables (`SPECKIT_{EXT_ID}_*`), | Variable | Description | Default | |----------|-------------|---------| | `SPECKIT_CATALOG_URL` | Override the full catalog stack with a single URL (backward compat) | Built-in default stack | -| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub API token for downloads | None | +| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated requests to GitHub-hosted URLs (`raw.githubusercontent.com`, `github.com`, `api.github.com`, `codeload.github.com`). Required when your catalog JSON or extension ZIPs are hosted in a private GitHub repository. | None | #### Example: Using a custom catalog for testing @@ -435,6 +435,21 @@ export SPECKIT_CATALOG_URL="http://localhost:8000/catalog.json" export SPECKIT_CATALOG_URL="https://example.com/staging/catalog.json" ``` +#### Example: Using a private GitHub-hosted catalog + +```bash +# Authenticate with a token (gh CLI, PAT, or GITHUB_TOKEN in CI) +export GITHUB_TOKEN=$(gh auth token) + +# Search a private catalog added via `specify extension catalog add` +specify extension search jira + +# Install from a private catalog +specify extension add jira-sync +``` + +The token is attached automatically to requests targeting GitHub domains. Non-GitHub catalog URLs are always fetched without credentials. + --- ## Extension Catalogs diff --git a/presets/README.md b/presets/README.md index 7d7b9ae8a2..abaeb27067 100644 --- a/presets/README.md +++ b/presets/README.md @@ -123,9 +123,25 @@ See [scaffold/](scaffold/) for a scaffold you can copy to create your own preset ## Environment Variables -| Variable | Description | -|----------|-------------| -| `SPECKIT_PRESET_CATALOG_URL` | Override the catalog URL (replaces all defaults) | +| Variable | Description | Default | +|----------|-------------|---------| +| `SPECKIT_PRESET_CATALOG_URL` | Override the full catalog stack with a single URL (replaces all defaults) | Built-in default stack | +| `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated requests to GitHub-hosted URLs (`raw.githubusercontent.com`, `github.com`, `api.github.com`, `codeload.github.com`). Required when your catalog JSON or preset ZIPs are hosted in a private GitHub repository. | None | + +#### Example: Using a private GitHub-hosted catalog + +```bash +# Authenticate with a token (gh CLI, PAT, or GITHUB_TOKEN in CI) +export GITHUB_TOKEN=$(gh auth token) + +# Search a private catalog added via `specify preset catalog add` +specify preset search my-template + +# Install from a private catalog +specify preset add my-template +``` + +The token is attached automatically to requests targeting GitHub domains. Non-GitHub catalog URLs are always fetched without credentials. ## Configuration Files diff --git a/src/specify_cli/_github_http.py b/src/specify_cli/_github_http.py new file mode 100644 index 0000000000..ee68a8325c --- /dev/null +++ b/src/specify_cli/_github_http.py @@ -0,0 +1,80 @@ +"""Shared GitHub-authenticated HTTP helpers. + +Used by both ExtensionCatalog and PresetCatalog to attach +GITHUB_TOKEN / GH_TOKEN credentials to requests targeting +GitHub-hosted domains, while preventing token leakage to +third-party hosts on redirects. +""" + +import os +import urllib.request +from urllib.parse import urlparse +from typing import Dict + +# GitHub-owned hostnames that should receive the Authorization header. +# Includes codeload.github.com because GitHub archive URL downloads +# (e.g. /archive/refs/tags/.zip) redirect there and require auth +# for private repositories. +GITHUB_HOSTS = frozenset({ + "raw.githubusercontent.com", + "github.com", + "api.github.com", + "codeload.github.com", +}) + + +def build_github_request(url: str) -> urllib.request.Request: + """Build a urllib Request, adding a GitHub auth header when available. + + Reads GITHUB_TOKEN or GH_TOKEN from the environment and attaches an + ``Authorization: Bearer `` header when the target hostname is one + of the known GitHub-owned domains. Non-GitHub URLs are returned as plain + requests so credentials are never leaked to third-party hosts. + """ + headers: Dict[str, str] = {} + github_token = (os.environ.get("GITHUB_TOKEN") or "").strip() + gh_token = (os.environ.get("GH_TOKEN") or "").strip() + token = github_token or gh_token or None + hostname = (urlparse(url).hostname or "").lower() + if token and hostname in GITHUB_HOSTS: + headers["Authorization"] = f"Bearer {token}" + return urllib.request.Request(url, headers=headers) + + +class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler): + """Redirect handler that drops the Authorization header when leaving GitHub. + + Prevents token leakage to CDNs or other third-party hosts that GitHub + may redirect to (e.g. S3 for release asset downloads, objects.githubusercontent.com). + Auth is preserved as long as the redirect target remains within GITHUB_HOSTS. + """ + + def redirect_request(self, req, fp, code, msg, headers, newurl): + original_auth = req.get_header("Authorization") + new_req = super().redirect_request(req, fp, code, msg, headers, newurl) + if new_req is not None: + hostname = (urlparse(newurl).hostname or "").lower() + if hostname in GITHUB_HOSTS: + if original_auth: + new_req.add_unredirected_header("Authorization", original_auth) + else: + new_req.headers.pop("Authorization", None) + new_req.unredirected_hdrs.pop("Authorization", None) + return new_req + + +def open_github_url(url: str, timeout: int = 10): + """Open a URL with GitHub auth, stripping the header on cross-host redirects. + + When the request carries an Authorization header, a custom redirect + handler drops that header if the redirect target is not a GitHub-owned + domain, preventing token leakage to CDNs or other third-party hosts + that GitHub may redirect to (e.g. S3 for release asset downloads). + """ + req = build_github_request(url) + + if not req.get_header("Authorization"): + return urllib.request.urlopen(req, timeout=timeout) + + opener = urllib.request.build_opener(_StripAuthOnRedirect) + return opener.open(req, timeout=timeout) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 26ceab4034..916038cd5f 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1539,6 +1539,22 @@ def _validate_catalog_url(self, url: str) -> None: if not parsed.netloc: raise ValidationError("Catalog URL must be a valid URL with a host.") + def _make_request(self, url: str): + """Build a urllib Request, adding a GitHub auth header when available. + + Delegates to :func:`specify_cli._github_http.build_github_request`. + """ + from specify_cli._github_http import build_github_request + return build_github_request(url) + + def _open_url(self, url: str, timeout: int = 10): + """Open a URL with GitHub auth, stripping the header on cross-host redirects. + + Delegates to :func:`specify_cli._github_http.open_github_url`. + """ + from specify_cli._github_http import open_github_url + return open_github_url(url, timeout) + def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]: """Load catalog stack configuration from a YAML file. @@ -1695,7 +1711,6 @@ def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False Raises: ExtensionError: If catalog cannot be fetched or has invalid format """ - import urllib.request import urllib.error # Determine cache file paths (backward compat for default catalog) @@ -1729,7 +1744,7 @@ def _fetch_single_catalog(self, entry: CatalogEntry, force_refresh: bool = False # Fetch from network try: - with urllib.request.urlopen(entry.url, timeout=10) as response: + with self._open_url(entry.url, timeout=10) as response: catalog_data = json.loads(response.read()) if "schema_version" not in catalog_data or "extensions" not in catalog_data: @@ -1843,10 +1858,9 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: catalog_url = self.get_catalog_url() try: - import urllib.request import urllib.error - with urllib.request.urlopen(catalog_url, timeout=10) as response: + with self._open_url(catalog_url, timeout=10) as response: catalog_data = json.loads(response.read()) # Validate catalog structure @@ -1957,7 +1971,6 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non Raises: ExtensionError: If extension not found or download fails """ - import urllib.request import urllib.error # Get extension info from catalog @@ -1997,7 +2010,7 @@ def download_extension(self, extension_id: str, target_dir: Optional[Path] = Non # Download the ZIP file try: - with urllib.request.urlopen(download_url, timeout=60) as response: + with self._open_url(download_url, timeout=60) as response: zip_data = response.read() zip_path.write_bytes(zip_data) diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index ed33f992c3..24de73521e 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -1831,6 +1831,22 @@ def _validate_catalog_url(self, url: str) -> None: "Catalog URL must be a valid URL with a host." ) + def _make_request(self, url: str): + """Build a urllib Request, adding a GitHub auth header when available. + + Delegates to :func:`specify_cli._github_http.build_github_request`. + """ + from specify_cli._github_http import build_github_request + return build_github_request(url) + + def _open_url(self, url: str, timeout: int = 10): + """Open a URL with GitHub auth, stripping the header on cross-host redirects. + + Delegates to :func:`specify_cli._github_http.open_github_url`. + """ + from specify_cli._github_http import open_github_url + return open_github_url(url, timeout) + def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]: """Load catalog stack configuration from a YAML file. @@ -2013,10 +2029,7 @@ def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = pass try: - import urllib.request - import urllib.error - - with urllib.request.urlopen(entry.url, timeout=10) as response: + with self._open_url(entry.url, timeout=10) as response: catalog_data = json.loads(response.read()) if ( @@ -2109,10 +2122,7 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: pass try: - import urllib.request - import urllib.error - - with urllib.request.urlopen(catalog_url, timeout=10) as response: + with self._open_url(catalog_url, timeout=10) as response: catalog_data = json.loads(response.read()) if ( @@ -2231,7 +2241,6 @@ def download_pack( Raises: PresetError: If pack not found or download fails """ - import urllib.request import urllib.error pack_info = self.get_pack_info(pack_id) @@ -2283,7 +2292,7 @@ def download_pack( zip_path = target_dir / zip_filename try: - with urllib.request.urlopen(download_url, timeout=60) as response: + with self._open_url(download_url, timeout=60) as response: zip_data = response.read() zip_path.write_bytes(zip_data) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index fdeb5a24ee..e6a206c069 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2416,6 +2416,215 @@ def test_clear_cache(self, temp_dir): assert not catalog.cache_file.exists() assert not catalog.cache_metadata_file.exists() + # --- _make_request / GitHub auth --- + + def _make_catalog(self, temp_dir): + project_dir = temp_dir / "project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + return ExtensionCatalog(project_dir) + + def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch): + """Without a token, requests carry no Authorization header.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_whitespace_only_github_token_ignored(self, temp_dir, monkeypatch): + """A whitespace-only GITHUB_TOKEN is treated as unset.""" + monkeypatch.setenv("GITHUB_TOKEN", " ") + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, temp_dir, monkeypatch): + """When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback.""" + monkeypatch.setenv("GITHUB_TOKEN", " ") + monkeypatch.setenv("GH_TOKEN", "ghp_fallback") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert req.get_header("Authorization") == "Bearer ghp_fallback" + + def test_make_request_github_token_added_for_raw_githubusercontent(self, temp_dir, monkeypatch): + """GITHUB_TOKEN is attached for raw.githubusercontent.com URLs.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_make_request_gh_token_fallback(self, temp_dir, monkeypatch): + """GH_TOKEN is used when GITHUB_TOKEN is absent.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip") + assert req.get_header("Authorization") == "Bearer ghp_ghtoken" + + def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch): + """GITHUB_TOKEN takes precedence over GH_TOKEN when both are set.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary") + monkeypatch.setenv("GH_TOKEN", "ghp_secondary") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://api.github.com/repos/org/repo") + assert req.get_header("Authorization") == "Bearer ghp_primary" + + def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch): + """Auth header is never attached to non-GitHub URLs to prevent credential leakage.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://internal.example.com/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch): + """Auth header is not attached to hosts that include github.com as a suffix.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/ext.zip") + assert "Authorization" not in req.headers + + def test_make_request_token_not_added_for_github_in_path(self, temp_dir, monkeypatch): + """Auth header is not attached when github.com appears only in the URL path.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/ext.zip") + assert "Authorization" not in req.headers + + def test_make_request_token_not_added_for_github_in_query(self, temp_dir, monkeypatch): + """Auth header is not attached when github.com appears only in the query string.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/ext.zip") + assert "Authorization" not in req.headers + + def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch): + """GITHUB_TOKEN is attached for api.github.com URLs.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch): + """GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects).""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = self._make_catalog(temp_dir) + req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_redirect_preserves_auth_for_github_to_codeload(self): + """Auth header is preserved when GitHub redirects to codeload.github.com.""" + from specify_cli._github_http import _StripAuthOnRedirect + from urllib.request import Request + import io + + handler = _StripAuthOnRedirect() + original_url = "https://github.com/org/repo/archive/refs/tags/v1.zip" + redirect_url = "https://codeload.github.com/org/repo/zip/refs/tags/v1" + req = Request(original_url, headers={"Authorization": "Bearer ghp_test"}) + fp = io.BytesIO(b"") + new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url) + assert new_req is not None + auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization") + assert auth == "Bearer ghp_test" + + def test_redirect_strips_auth_for_github_to_external(self): + """Auth header is stripped when GitHub redirects to a non-GitHub host.""" + from specify_cli._github_http import _StripAuthOnRedirect + from urllib.request import Request + import io + + handler = _StripAuthOnRedirect() + original_url = "https://github.com/org/repo/releases/download/v1/asset.zip" + redirect_url = "https://objects.githubusercontent.com/github-production-release-asset/12345" + req = Request(original_url, headers={"Authorization": "Bearer ghp_test"}) + fp = io.BytesIO(b"") + new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url) + assert new_req is not None + auth_header = new_req.headers.get("Authorization") + auth_unredirected = new_req.unredirected_hdrs.get("Authorization") + assert auth_header is None + assert auth_unredirected is None + + def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch): + """_fetch_single_catalog passes Authorization header via opener for GitHub URLs.""" + from unittest.mock import patch, MagicMock + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = self._make_catalog(temp_dir) + + catalog_data = {"schema_version": "1.0", "extensions": {}} + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(catalog_data).encode() + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + captured = {} + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured["req"] = req + return mock_response + + mock_opener.open.side_effect = fake_open + + entry = CatalogEntry( + url="https://raw.githubusercontent.com/org/repo/main/catalog.json", + name="private", + priority=1, + install_allowed=True, + ) + + with patch("urllib.request.build_opener", return_value=mock_opener): + catalog._fetch_single_catalog(entry, force_refresh=True) + + assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" + + def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch): + """download_extension passes Authorization header via opener for GitHub URLs.""" + from unittest.mock import patch, MagicMock + import zipfile, io + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = self._make_catalog(temp_dir) + + # Build a minimal valid ZIP in memory + 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() + + mock_response = MagicMock() + mock_response.read.return_value = zip_bytes + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + captured = {} + + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured["req"] = req + return mock_response + + mock_opener.open.side_effect = fake_open + + ext_info = { + "id": "test-ext", + "name": "Test Extension", + "version": "1.0.0", + "download_url": "https://github.com/org/repo/releases/download/v1/test-ext.zip", + } + + with patch.object(catalog, "get_extension_info", return_value=ext_info), \ + patch("urllib.request.build_opener", return_value=mock_opener): + catalog.download_extension("test-ext", target_dir=temp_dir) + + assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" + + # ===== CatalogEntry Tests ===== diff --git a/tests/test_presets.py b/tests/test_presets.py index 64bdc1f0b5..ee4a6dddb1 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1363,6 +1363,166 @@ def test_env_var_catalog_url(self, project_dir, monkeypatch): catalog = PresetCatalog(project_dir) assert catalog.get_catalog_url() == "https://custom.example.com/catalog.json" + # --- _make_request / GitHub auth --- + + def test_make_request_no_token_no_auth_header(self, project_dir, monkeypatch): + """Without a token, requests carry no Authorization header.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_whitespace_only_github_token_ignored(self, project_dir, monkeypatch): + """A whitespace-only GITHUB_TOKEN is treated as unset.""" + monkeypatch.setenv("GITHUB_TOKEN", " ") + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, project_dir, monkeypatch): + """When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback.""" + monkeypatch.setenv("GITHUB_TOKEN", " ") + monkeypatch.setenv("GH_TOKEN", "ghp_fallback") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert req.get_header("Authorization") == "Bearer ghp_fallback" + + def test_make_request_github_token_added_for_github_url(self, project_dir, monkeypatch): + """GITHUB_TOKEN is attached for raw.githubusercontent.com URLs.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + monkeypatch.delenv("GH_TOKEN", raising=False) + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_make_request_gh_token_fallback(self, project_dir, monkeypatch): + """GH_TOKEN is used when GITHUB_TOKEN is absent.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") + assert req.get_header("Authorization") == "Bearer ghp_ghtoken" + + def test_make_request_github_token_takes_precedence(self, project_dir, monkeypatch): + """GITHUB_TOKEN takes precedence over GH_TOKEN when both are set.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary") + monkeypatch.setenv("GH_TOKEN", "ghp_secondary") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://api.github.com/repos/org/repo") + assert req.get_header("Authorization") == "Bearer ghp_primary" + + def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch): + """GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects).""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0") + assert req.get_header("Authorization") == "Bearer ghp_testtoken" + + def test_make_request_token_not_added_for_non_github_url(self, project_dir, monkeypatch): + """Auth header is never attached to non-GitHub URLs to prevent credential leakage.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://internal.example.com/catalog.json") + assert "Authorization" not in req.headers + + def test_make_request_token_not_added_for_github_lookalike_host(self, project_dir, monkeypatch): + """Auth header is not attached to hosts that include github.com as a suffix.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/pack.zip") + assert "Authorization" not in req.headers + + def test_make_request_token_not_added_for_github_in_path(self, project_dir, monkeypatch): + """Auth header is not attached when github.com appears only in the URL path.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/pack.zip") + assert "Authorization" not in req.headers + + def test_make_request_token_not_added_for_github_in_query(self, project_dir, monkeypatch): + """Auth header is not attached when github.com appears only in the query string.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = PresetCatalog(project_dir) + req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/pack.zip") + assert "Authorization" not in req.headers + + def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch): + """_fetch_single_catalog passes Authorization header via opener for GitHub URLs.""" + from unittest.mock import patch, MagicMock + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = PresetCatalog(project_dir) + + catalog_data = {"schema_version": "1.0", "presets": {}} + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(catalog_data).encode() + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + captured = {} + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured["req"] = req + return mock_response + + mock_opener.open.side_effect = fake_open + + entry = PresetCatalogEntry( + url="https://raw.githubusercontent.com/org/repo/main/presets/catalog.json", + name="private", + priority=1, + install_allowed=True, + ) + + with patch("urllib.request.build_opener", return_value=mock_opener): + catalog._fetch_single_catalog(entry, force_refresh=True) + + assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" + + def test_download_pack_sends_auth_header(self, project_dir, monkeypatch): + """download_pack passes Authorization header via opener for GitHub URLs.""" + from unittest.mock import patch, MagicMock + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + catalog = PresetCatalog(project_dir) + + import io + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n") + zip_bytes = zip_buf.getvalue() + + mock_response = MagicMock() + mock_response.read.return_value = zip_bytes + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + + captured = {} + mock_opener = MagicMock() + + def fake_open(req, timeout=None): + captured["req"] = req + return mock_response + + mock_opener.open.side_effect = fake_open + + pack_info = { + "id": "test-pack", + "name": "Test Pack", + "version": "1.0.0", + "download_url": "https://github.com/org/repo/releases/download/v1/test-pack.zip", + "_install_allowed": True, + } + + with patch.object(catalog, "get_pack_info", return_value=pack_info), \ + patch("urllib.request.build_opener", return_value=mock_opener): + catalog.download_pack("test-pack", target_dir=project_dir) + + assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" + # ===== Integration Tests ===== From 171b65ac33a3bf51c23b9f7a5287032ed1ae72ba Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:04:04 -0500 Subject: [PATCH 120/184] docs: replace deprecated --ai flag with --integration in all documentation (#2359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: replace deprecated --ai flag with --integration in all documentation Replace all user-facing --ai, --ai-skills, and --ai-commands-dir references with their modern equivalents: - --ai → --integration - --ai-skills → --integration-options="--skills" - --ai-commands-dir → --integration generic --integration-options="--commands-dir " Updated files: - README.md (~17 occurrences) - docs/installation.md (~8 occurrences) - docs/upgrade.md (~11 occurrences) - docs/local-development.md (~5 occurrences) - CONTRIBUTING.md (1 occurrence) - extensions/EXTENSION-USER-GUIDE.md (1 occurrence) - src/specify_cli/__init__.py (docstring examples and error messages) Left unchanged: - CHANGELOG.md (historical record) - Test files (intentionally exercise deprecated flag path) - CLI flag implementation (backward compatibility) Closes #2358 * docs: address review feedback on pre-existing issues - Fix duplicate copilot example in README.md (replace with codex) - Fix invalid gemini --integration-options="--skills" example (gemini does not support --skills) - Update generic integration comment from 'Unsupported agent' to 'Bring your own agent; requires --commands-dir' - Clarify EXTENSION-USER-GUIDE.md: skills auto-register for skills-based integrations, not only with --integration-options * docs: replace bare 'AI agent' / 'AI assistant' with 'coding agent' throughout Full sweep across all documentation and user-facing CLI messages to align terminology. Bare references like 'AI agent', 'AI assistant', and 'AI Agent' are replaced with 'coding agent' or 'coding agent integration' as appropriate. Intentionally left unchanged: - 'AI coding agent' (already correct expanded form) - Deprecated --ai flag help text and error messages (describes the deprecated flag itself) - Community extension descriptions (external project text) - 'generated by an AI' in CONTRIBUTING.md (general AI, not agent) * docs: address review — remove deprecated --offline, qualify --skills scope - Remove --offline from docstring examples (deprecated no-op) - Remove --offline from CONTRIBUTING.md testing example - Replace --offline instructions in docs/installation.md with note that bundled assets are used by default - Qualify --integration-options="--skills" in README.md to note it only applies to integrations that support skills mode --- CONTRIBUTING.md | 4 +-- README.md | 42 ++++++++++++------------- docs/installation.md | 24 +++++++-------- docs/local-development.md | 10 +++--- docs/quickstart.md | 6 ++-- docs/upgrade.md | 22 +++++++------- extensions/EXTENSION-USER-GUIDE.md | 14 ++++----- src/specify_cli/__init__.py | 49 +++++++++++++++--------------- 8 files changed, 84 insertions(+), 87 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c44063b16f..5188d70a71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,7 +94,7 @@ uv pip install -e . # Ensure the `specify` binary in this environment points at your working tree so the agent runs the branch you're testing. # Initialize a test project using your local changes -uv run specify init /speckit-test --ai --offline +uv run specify init /speckit-test --integration cd /speckit-test # Open in your agent @@ -102,7 +102,7 @@ cd /speckit-test #### Manual testing process -Any change that affects a slash command's behavior requires manually testing that command through an AI agent and submitting results with the PR. +Any change that affects a slash command's behavior requires manually testing that command through a coding agent and submitting results with the PR. 1. **Identify affected commands** — use the [prompt below](#determining-which-tests-to-run) to have your agent analyze your changed files and determine which commands need testing. 2. **Set up a test project** — scaffold from your local branch (see [Testing setup](#testing-setup)). diff --git a/README.md b/README.md index 3aa320fc73..419e7f919a 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,9 @@ And use the tool directly: specify init # Or initialize in existing project -specify init . --ai copilot +specify init . --integration copilot # or -specify init --here --ai copilot +specify init --here --integration copilot # Check installed tools specify check @@ -105,9 +105,9 @@ Run directly without installing: uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init # Or initialize in existing project -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --ai copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . --integration copilot # or -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot ``` **Benefits of persistent installation:** @@ -123,7 +123,7 @@ If your environment blocks access to PyPI or GitHub, see the [Enterprise / Air-G ### 2. Establish project principles -Launch your AI assistant in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead. +Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead. Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development. @@ -302,7 +302,7 @@ Run `specify integration list` to see all available integrations in your install ## Available Slash Commands -After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`. +After running `specify init`, your AI coding agent will have access to these slash commands for structured development. For integrations that support skills mode, passing `--integration --integration-options="--skills"` installs agent skills instead of slash-command prompt files. #### Core Commands @@ -475,37 +475,37 @@ specify init --here --force ![Specify CLI bootstrapping a new project in the terminal](./media/specify_cli.gif) -You will be prompted to select the AI agent you are using. You can also proactively specify it directly in the terminal: +You will be prompted to select the coding agent integration you are using. You can also proactively specify it directly in the terminal: ```bash -specify init --ai copilot -specify init --ai gemini -specify init --ai copilot +specify init --integration copilot +specify init --integration gemini +specify init --integration codex # Or in current directory: -specify init . --ai copilot -specify init . --ai codex --ai-skills +specify init . --integration copilot +specify init . --integration codex --integration-options="--skills" # or use --here flag -specify init --here --ai copilot -specify init --here --ai codex --ai-skills +specify init --here --integration copilot +specify init --here --integration codex --integration-options="--skills" # Force merge into a non-empty current directory -specify init . --force --ai copilot +specify init . --force --integration copilot # or -specify init --here --force --ai copilot +specify init --here --force --integration copilot ``` The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command: ```bash -specify init --ai copilot --ignore-agent-tools +specify init --integration copilot --ignore-agent-tools ``` ### **STEP 1:** Establish project principles -Go to the project folder and run your AI agent. In our example, we're using `claude`. +Go to the project folder and run your coding agent. In our example, we're using `claude`. ![Bootstrapping Claude Code environment](./media/bootstrap-claude-code.gif) @@ -517,7 +517,7 @@ The first step should be establishing your project's governing principles using /speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements. Include governance for how these principles should guide technical decisions and implementation choices. ``` -This step creates or updates the `.specify/memory/constitution.md` file with your project's foundational guidelines that the AI agent will reference during specification, planning, and implementation phases. +This step creates or updates the `.specify/memory/constitution.md` file with your project's foundational guidelines that the coding agent will reference during specification, planning, and implementation phases. ### **STEP 2:** Create project specifications @@ -725,9 +725,9 @@ The `/speckit.implement` command will: - Provide progress updates and handle errors appropriately > [!IMPORTANT] -> The AI agent will execute local CLI commands (such as `dotnet`, `npm`, etc.) - make sure you have the required tools installed on your machine. +> The coding agent will execute local CLI commands (such as `dotnet`, `npm`, etc.) - make sure you have the required tools installed on your machine. -Once the implementation is complete, test the application and resolve any runtime errors that may not be visible in CLI logs (e.g., browser console errors). You can copy and paste such errors back to your AI agent for resolution. +Once the implementation is complete, test the application and resolve any runtime errors that may not be visible in CLI logs (e.g., browser console errors). You can copy and paste such errors back to your coding agent for resolution.
diff --git a/docs/installation.md b/docs/installation.md index c99810f706..7f6aa089b7 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -39,16 +39,16 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init . uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here ``` -### Specify AI Agent +### Specify Integration -You can proactively specify your AI agent during initialization: +You can proactively specify your coding agent integration during initialization: ```bash -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai claude -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai gemini -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai copilot -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai codebuddy -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai pi +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration claude +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration gemini +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration codebuddy +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration pi ``` ### Specify Script Type (Shell vs PowerShell) @@ -73,7 +73,7 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --ai claude --ignore-agent-tools +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --integration claude --ignore-agent-tools ``` ## Verification @@ -86,7 +86,7 @@ specify version This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name. -After initialization, you should see the following commands available in your AI agent: +After initialization, you should see the following commands available in your coding agent: - `/speckit.specify` - Create specifications - `/speckit.plan` - Generate implementation plans @@ -131,12 +131,10 @@ pip install --no-index --find-links=./dist specify-cli ```bash # Initialize a project — no GitHub access needed -specify init my-project --ai claude --offline +specify init my-project --integration claude ``` -The `--offline` flag tells the CLI to use the templates, commands, and scripts bundled inside the wheel instead of downloading from GitHub. - -> **Deprecation notice:** Starting with v0.6.0, `specify init` will use bundled assets by default and the `--offline` flag will be removed. The GitHub download path will be retired because bundled assets eliminate the need for network access, avoid proxy/firewall issues, and guarantee that templates always match the installed CLI version. No action will be needed — `specify init` will simply work without network access out of the box. +Bundled assets are used by default — no network access is required. > **Note:** Python 3.11+ is required. diff --git a/docs/local-development.md b/docs/local-development.md index a23ea1d88f..4776204d7d 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -20,7 +20,7 @@ You can execute the CLI via the module entrypoint without installing anything: ```bash # From repo root python -m src.specify_cli --help -python -m src.specify_cli init demo-project --ai claude --ignore-agent-tools --script sh +python -m src.specify_cli init demo-project --integration claude --ignore-agent-tools --script sh ``` If you prefer invoking the script file style (uses shebang): @@ -52,7 +52,7 @@ Re-running after code edits requires no reinstall because of editable mode. `uvx` can run from a local path (or a Git ref) to simulate user flows: ```bash -uvx --from . specify init demo-uvx --ai copilot --ignore-agent-tools --script sh +uvx --from . specify init demo-uvx --integration copilot --ignore-agent-tools --script sh ``` You can also point uvx at a specific branch without merging: @@ -69,14 +69,14 @@ If you're in another directory, use an absolute path instead of `.`: ```bash uvx --from /mnt/c/GitHub/spec-kit specify --help -uvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --ai copilot --ignore-agent-tools --script sh +uvx --from /mnt/c/GitHub/spec-kit specify init demo-anywhere --integration copilot --ignore-agent-tools --script sh ``` Set an environment variable for convenience: ```bash export SPEC_KIT_SRC=/mnt/c/GitHub/spec-kit -uvx --from "$SPEC_KIT_SRC" specify init demo-env --ai copilot --ignore-agent-tools --script ps +uvx --from "$SPEC_KIT_SRC" specify init demo-env --integration copilot --ignore-agent-tools --script ps ``` (Optional) Define a shell function: @@ -123,7 +123,7 @@ When testing `init --here` in a dirty directory, create a temp workspace: ```bash mkdir /tmp/spec-test && cd /tmp/spec-test -python -m src.specify_cli init --here --ai claude --ignore-agent-tools --script sh # if repo copied here +python -m src.specify_cli init --here --integration claude --ignore-agent-tools --script sh # if repo copied here ``` Or copy only the modified CLI portion if you want a lighter sandbox. diff --git a/docs/quickstart.md b/docs/quickstart.md index 5c0f009306..0e6c0ab9d4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -42,7 +42,7 @@ uvx --from git+https://github.com/github/spec-kit.git specify init ` | Update slash commands, templates, and scripts in your project | +| **Project Files** | `specify init --here --force --integration ` | Update slash commands, templates, and scripts in your project | | **Both** | Run CLI upgrade, then project update | Recommended for major version updates | --- @@ -32,7 +32,7 @@ uv tool install specify-cli --force --from git+https://github.com/github/spec-ki Specify the desired release tag: ```bash -uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --ai copilot +uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here --integration copilot ``` ### If you installed with `pipx` @@ -82,7 +82,7 @@ The `specs/` directory is completely excluded from template packages and will ne Run this inside your project directory: ```bash -specify init --here --force --ai +specify init --here --force --integration ``` Replace `` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md) @@ -90,7 +90,7 @@ Replace `` with your AI coding agent. Refer to this list of [Support **Example:** ```bash -specify init --here --force --ai copilot +specify init --here --force --integration copilot ``` ### Understanding the `--force` flag @@ -124,7 +124,7 @@ Without `--force`, shared infrastructure files that already exist are skipped cp .specify/memory/constitution.md .specify/memory/constitution-backup.md # 2. Run the upgrade -specify init --here --force --ai copilot +specify init --here --force --integration copilot # 3. Restore your customized constitution mv .specify/memory/constitution-backup.md .specify/memory/constitution.md @@ -182,7 +182,7 @@ Restart your IDE to refresh the command list. uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git # Update project files to get new commands -specify init --here --force --ai copilot +specify init --here --force --integration copilot # Restore your constitution if customized git restore .specify/memory/constitution.md @@ -199,7 +199,7 @@ cp -r .specify/templates /tmp/templates-backup uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git # 3. Update project -specify init --here --force --ai copilot +specify init --here --force --integration copilot # 4. Restore customizations mv /tmp/constitution-backup.md .specify/memory/constitution.md @@ -232,7 +232,7 @@ If you initialized your project with `--no-git`, you can still upgrade: cp .specify/memory/constitution.md /tmp/constitution-backup.md # Run upgrade -specify init --here --force --ai copilot --no-git +specify init --here --force --integration copilot --no-git # Restore customizations mv /tmp/constitution-backup.md .specify/memory/constitution.md @@ -253,13 +253,13 @@ The `--no-git` flag tells Spec Kit to **skip git repository initialization**. Th **During initial setup:** ```bash -specify init my-project --ai copilot --no-git +specify init my-project --integration copilot --no-git ``` **During upgrade:** ```bash -specify init --here --force --ai copilot --no-git +specify init --here --force --integration copilot --no-git ``` ### What `--no-git` does NOT do @@ -367,7 +367,7 @@ Only Spec Kit infrastructure files: - **Use `--force` flag** - Skip this confirmation entirely: ```bash - specify init --here --force --ai copilot + specify init --here --force --integration copilot ``` **When you see this warning:** diff --git a/extensions/EXTENSION-USER-GUIDE.md b/extensions/EXTENSION-USER-GUIDE.md index 77e321b9cf..c3391dbc75 100644 --- a/extensions/EXTENSION-USER-GUIDE.md +++ b/extensions/EXTENSION-USER-GUIDE.md @@ -153,7 +153,7 @@ This will: 2. Validate the manifest 3. Check compatibility with your spec-kit version 4. Install to `.specify/extensions/jira/` -5. Register commands with your AI agent +5. Register commands with your coding agent 6. Create config template ### Install from URL @@ -189,7 +189,7 @@ Provided commands: ### Automatic Agent Skill Registration -If your project was initialized with `--ai-skills`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification. +If your project uses a skills-based integration (e.g., `--integration claude`, `--integration codex`) or was initialized with `--integration-options="--skills"`, extension commands are **automatically registered as agent skills** during installation. This ensures that extensions are discoverable by agents that use the [agentskills.io](https://agentskills.io) skill specification. ```text ✓ Extension installed successfully! @@ -208,7 +208,7 @@ When an extension is removed, its corresponding skills are also cleaned up autom ### Using Extension Commands -Extensions add commands that appear in your AI agent (Claude Code): +Extensions add commands that appear in your coding agent (Claude Code): ```text # In Claude Code @@ -795,12 +795,12 @@ specify extension add --dev /path/to/extension ### Command Not Available -**Issue**: Extension command not appearing in AI agent +**Issue**: Extension command not appearing in coding agent **Solutions**: 1. Check extension is enabled: `specify extension list` -2. Restart AI agent (Claude Code) +2. Restart coding agent (Claude Code) 3. Check command file exists: ```bash @@ -834,8 +834,8 @@ specify extension add --dev /path/to/extension **Solutions**: 1. Check MCP server is installed -2. Check AI agent MCP configuration -3. Restart AI agent +2. Check coding agent MCP configuration +3. Restart coding agent 4. Check extension requirements: `specify extension info jira` ### Permission Denied diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 618492f306..d5f5aba2d5 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -967,7 +967,7 @@ def init( ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), - ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for AI agent tools like Claude Code"), + ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), force: bool = typer.Option(False, "--force", help="Force merge/overwrite when using --here (skip confirmation)"), @@ -997,29 +997,28 @@ def init( This command will: 1. Check that required tools are installed (git is optional) - 2. Let you choose your AI assistant + 2. Let you choose your coding agent integration 3. Download template from GitHub (or use bundled assets with --offline) 4. Initialize a fresh git repository (if not --no-git and no existing repo) - 5. Optionally set up AI assistant commands + 5. Optionally set up coding agent integration commands Examples: specify init my-project - specify init my-project --ai claude - specify init my-project --ai copilot --no-git + specify init my-project --integration claude + specify init my-project --integration copilot --no-git specify init --ignore-agent-tools my-project - specify init . --ai claude # Initialize in current directory - specify init . # Initialize in current directory (interactive AI selection) - specify init --here --ai claude # Alternative syntax for current directory - specify init --here --ai codex --ai-skills - specify init --here --ai codebuddy - specify init --here --ai vibe # Initialize with Mistral Vibe support + specify init . --integration claude # Initialize in current directory + specify init . # Initialize in current directory (interactive integration selection) + specify init --here --integration claude # Alternative syntax for current directory + specify init --here --integration codex --integration-options="--skills" + specify init --here --integration codebuddy + specify init --here --integration vibe # Initialize with Mistral Vibe support specify init --here specify init --here --force # Skip confirmation when current directory not empty - specify init my-project --ai claude # Claude installs skills by default - specify init --here --ai gemini --ai-skills - specify init my-project --ai generic --ai-commands-dir .myagent/commands/ # Unsupported agent - specify init my-project --offline # Use bundled assets (no network access) - specify init my-project --ai claude --preset healthcare-compliance # With preset + specify init my-project --integration claude # Claude installs skills by default + specify init --here --integration gemini + specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir + specify init my-project --integration claude --preset healthcare-compliance # With preset """ show_banner() @@ -1029,14 +1028,14 @@ def init( if ai_assistant and ai_assistant.startswith("--"): console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'") console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?") - console.print("[yellow]Example:[/yellow] specify init --ai claude --here") + console.print("[yellow]Example:[/yellow] specify init --integration claude --here") console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}") raise typer.Exit(1) if ai_commands_dir and ai_commands_dir.startswith("--"): console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'") console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?") - console.print("[yellow]Example:[/yellow] specify init --ai generic --ai-commands-dir .myagent/commands/") + console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"") raise typer.Exit(1) if ai_assistant: @@ -1170,7 +1169,7 @@ def init( ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} selected_ai = select_with_arrows( ai_choices, - "Choose your AI assistant:", + "Choose your coding agent integration:", "copilot" ) @@ -1241,7 +1240,7 @@ def init( else: selected_script = default_script - console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") + console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") tracker = StepTracker("Initialize Specify Project") @@ -1250,7 +1249,7 @@ def init( tracker.add("precheck", "Check required tools") tracker.complete("precheck", "ok") - tracker.add("ai-select", "Select AI assistant") + tracker.add("ai-select", "Select coding agent integration") tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) @@ -1565,7 +1564,7 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" return f"/speckit.{name}" - steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:") + steps_lines.append(f"{step_num}. Start using {usage_label} with your coding agent:") steps_lines.append(f" {step_num}.1 [cyan]{_display_cmd('constitution')}[/] - Establish project principles") steps_lines.append(f" {step_num}.2 [cyan]{_display_cmd('specify')}[/] - Create baseline specification") @@ -1636,7 +1635,7 @@ def check(): console.print("[dim]Tip: Install git for repository management[/dim]") if not any(agent_results.values()): - console.print("[dim]Tip: Install an AI assistant for the best experience[/dim]") + console.print("[dim]Tip: Install a coding agent for the best experience[/dim]") @app.command() def version(): @@ -1882,7 +1881,7 @@ def get_speckit_version() -> str: integration_app = typer.Typer( name="integration", - help="Manage AI agent integrations", + help="Manage coding agent integrations", add_completion=False, ) app.add_typer(integration_app, name="integration") @@ -2020,7 +2019,7 @@ def integration_list( console.print(table) return - table = Table(title="AI Agent Integrations") + table = Table(title="Coding Agent Integrations") table.add_column("Key", style="cyan") table.add_column("Name") table.add_column("Status") From 77ca5f4ed50c2635a8a1395c0008781e941ff37a Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:42:21 +0100 Subject: [PATCH 121/184] catalog: add m365 community extension Add Microsoft 365 Integration to community catalog and README. Ingests Teams messages, files, and meeting transcripts as Markdown for use with speckit specify. --- README.md | 1 + extensions/catalog.community.json | 37 ++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 419e7f919a..636c5574de 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ The following community-contributed extensions are available in [`catalog.commun | Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) | | Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) | | MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | +| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `docs` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 0c6e5cbfb3..b7ad69122f 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-24T14:00:00Z", + "updated_at": "2026-04-27T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -941,6 +941,41 @@ "created_at": "2026-03-17T00:00:00Z", "updated_at": "2026-03-17T00:00:00Z" }, + "m365": { + "name": "Microsoft 365 Integration", + "id": "m365", + "description": "Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation.", + "author": "BenBtg", + "version": "1.0.0", + "download_url": "https://github.com/BenBtg/spec-kit-m365/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/BenBtg/spec-kit-m365", + "homepage": "https://github.com/BenBtg/spec-kit-m365", + "documentation": "https://github.com/BenBtg/spec-kit-m365/blob/main/README.md", + "changelog": "https://github.com/BenBtg/spec-kit-m365/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": ["m365 CLI (CLI for Microsoft 365)"] + }, + "provides": { + "commands": 3, + "hooks": 0 + }, + "tags": [ + "microsoft-365", + "teams", + "meetings", + "transcripts", + "collaboration", + "extraction", + "summarization" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, "maqa": { "name": "MAQA — Multi-Agent & Quality Assurance", "id": "maqa", From 3a7f64c8a5298094ea327f9093df1b906788a48b Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Tue, 28 Apr 2026 18:47:22 +0500 Subject: [PATCH 122/184] fix(extensions): use explicit UTF-8 encoding when reading manifest YAML (#2370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(extensions): use explicit UTF-8 encoding when reading manifest YAML On Windows, Python's open() defaults to the system locale encoding (e.g., GBK on Chinese Windows), which causes UnicodeDecodeError when extension.yml or preset.yml contains non-ASCII content such as Chinese characters in description fields. Add encoding='utf-8' to ExtensionManifest._load_yaml and PresetManifest._load_yaml so manifests are read consistently across platforms. Fixes #2325 * test(extensions,presets): add UTF-8 manifest regression tests for #2325 Positive: extension.yml/preset.yml with non-ASCII (Chinese + emoji) descriptions load correctly when written as UTF-8 bytes — fails on Windows without explicit encoding='utf-8'. Negative: files containing invalid UTF-8 bytes raise a clean error (ValidationError or UnicodeDecodeError), not a silent crash. * fix(extensions,presets): wrap I/O and decode errors as ValidationError Address remaining Copilot concerns on #2370: - Catch UnicodeDecodeError and OSError in both manifest loaders and re-raise as ValidationError / PresetValidationError so callers see a consistent error type, not a bare decode/IO traceback. - Validate that PresetManifest YAML root is a mapping (extensions.py already had this; presets.py was missing it). Treat None as {} for empty-file compatibility. - Tighten the negative regression tests to assert the specific message, and add a non-mapping-root test for PresetManifest matching the existing one for ExtensionManifest. --- src/specify_cli/extensions.py | 8 +++++++- src/specify_cli/presets.py | 17 +++++++++++++++-- tests/test_extensions.py | 29 +++++++++++++++++++++++++++++ tests/test_presets.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 916038cd5f..a419ebf1d2 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -139,12 +139,18 @@ def __init__(self, manifest_path: Path): def _load_yaml(self, path: Path) -> dict: """Load YAML file safely.""" try: - with open(path, 'r') as f: + with open(path, 'r', encoding='utf-8') as f: data = yaml.safe_load(f) except yaml.YAMLError as e: raise ValidationError(f"Invalid YAML in {path}: {e}") except FileNotFoundError: raise ValidationError(f"Manifest not found: {path}") + except UnicodeDecodeError as e: + raise ValidationError( + f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})" + ) + except OSError as e: + raise ValidationError(f"Could not read manifest {path}: {e}") if not isinstance(data, dict): raise ValidationError( f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}" diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 24de73521e..27054a77fc 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -136,12 +136,25 @@ def __init__(self, manifest_path: Path): def _load_yaml(self, path: Path) -> dict: """Load YAML file safely.""" try: - with open(path, 'r') as f: - return yaml.safe_load(f) or {} + with open(path, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) except yaml.YAMLError as e: raise PresetValidationError(f"Invalid YAML in {path}: {e}") except FileNotFoundError: raise PresetValidationError(f"Manifest not found: {path}") + except UnicodeDecodeError as e: + raise PresetValidationError( + f"Manifest is not valid UTF-8: {path} ({e.reason} at byte {e.start})" + ) + except OSError as e: + raise PresetValidationError(f"Could not read manifest {path}: {e}") + if data is None: + return {} + if not isinstance(data, dict): + raise PresetValidationError( + f"Manifest must be a YAML mapping, got {type(data).__name__}: {path}" + ) + return data def _validate(self): """Validate manifest structure and required fields.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index e6a206c069..c5be0ab4f3 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -225,6 +225,35 @@ def test_non_mapping_yaml_raises_validation_error(self, temp_dir): with pytest.raises(ValidationError, match="YAML mapping"): ExtensionManifest(manifest_path) + def test_utf8_non_ascii_description_loads(self, temp_dir, valid_manifest_data): + """Regression for #2325: non-ASCII (UTF-8) description loads on any platform. + + On Windows, Python's default text-mode encoding is the locale codepage + (e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes + outside the ASCII range. The loader must open with encoding='utf-8'. + """ + import yaml + + valid_manifest_data["extension"]["description"] = "中文测试 — émojis 🚀" + manifest_path = temp_dir / "extension.yml" + # Write UTF-8 bytes explicitly so the test exercises the read path, + # not the (locale-dependent) write path. + manifest_path.write_bytes( + yaml.safe_dump(valid_manifest_data, allow_unicode=True).encode("utf-8") + ) + + manifest = ExtensionManifest(manifest_path) + assert manifest.description == "中文测试 — émojis 🚀" + + def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir): + """Negative case: file containing invalid UTF-8 bytes raises ValidationError, not raw UnicodeDecodeError.""" + manifest_path = temp_dir / "extension.yml" + # 0xFF/0xFE are not valid UTF-8 lead bytes. + manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n") + + with pytest.raises(ValidationError, match="not valid UTF-8"): + ExtensionManifest(manifest_path) + def test_invalid_extension_id(self, temp_dir, valid_manifest_data): """Test manifest with invalid extension ID format.""" import yaml diff --git a/tests/test_presets.py b/tests/test_presets.py index ee4a6dddb1..4b167ed9be 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -160,6 +160,38 @@ def test_invalid_yaml(self, temp_dir): with pytest.raises(PresetValidationError, match="Invalid YAML"): PresetManifest(bad_file) + def test_utf8_non_ascii_description_loads(self, temp_dir, valid_pack_data): + """Regression for #2325: non-ASCII (UTF-8) description loads on any platform. + + On Windows, Python's default text-mode encoding is the locale codepage + (e.g. cp1252/GBK), which raises UnicodeDecodeError on UTF-8 bytes + outside the ASCII range. The loader must open with encoding='utf-8'. + """ + valid_pack_data["preset"]["description"] = "中文测试 — émojis 🚀" + manifest_path = temp_dir / "preset.yml" + manifest_path.write_bytes( + yaml.safe_dump(valid_pack_data, allow_unicode=True).encode("utf-8") + ) + + manifest = PresetManifest(manifest_path) + assert manifest.description == "中文测试 — émojis 🚀" + + def test_invalid_utf8_bytes_raises_validation_error(self, temp_dir): + """Negative case: file containing invalid UTF-8 bytes raises PresetValidationError, not raw UnicodeDecodeError.""" + manifest_path = temp_dir / "preset.yml" + manifest_path.write_bytes(b"\xff\xfe not valid utf-8 \xff\n") + + with pytest.raises(PresetValidationError, match="not valid UTF-8"): + PresetManifest(manifest_path) + + def test_non_mapping_yaml_raises_validation_error(self, temp_dir): + """Manifest whose YAML root is a scalar or list raises PresetValidationError, not TypeError.""" + manifest_path = temp_dir / "preset.yml" + for bad_content in ("42\n", "[1, 2]\n"): + manifest_path.write_text(bad_content, encoding="utf-8") + with pytest.raises(PresetValidationError, match="YAML mapping"): + PresetManifest(manifest_path) + def test_missing_schema_version(self, temp_dir, valid_pack_data): """Test missing schema_version field.""" del valid_pack_data["schema_version"] From a91897923639db5a282081aa5946eebae1d7b507 Mon Sep 17 00:00:00 2001 From: adaumann <94932945+adaumann@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:58:30 +0200 Subject: [PATCH 123/184] feat: Speckit preset fiction book v1.7 - Support for RAG (Chroma DB) offline semantic search (#2367) * Update preset-fiction-book-writing to community catalog - Preset ID: fiction-book-writing - Version: 1.5.0 - Author: Andreas Daumann - Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc. V1.5.0: Support interactive, audiobooks, series, workflow corrections * Add fiction-book-writing preset to community catalog - Preset ID: fiction-book-writing - Version: 1.6.0 - Author: Andreas Daumann - Description: Added support for 12 languages, export with templates, cover builder, bio builder, workflow fixes * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed update_at for fiction-book-writing preset * Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed description for fiction-book-writing * "Add fiction-book-preset to community catalog - Preset ID: fiction-book-writing - Version: 1.7.0 - Author: Andreas Daumann - Description: It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. V1.7.0: Support for offline semantic search. * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update presets/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add fiction-book-writing to community catalog - Preset ID: fiction-book-writing - Version: 1.7.0 - Author: Andreas Daumann - Description: Spec-Driven Development for novel and long-form fiction. RAG support * Update docs/community/presets.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/community/presets.md | 2 +- presets/catalog.community.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index 03ac777b80..c48f9a3e5b 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -11,7 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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) | | Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | -| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 1 script | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | | Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) | | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index caf28e5041..8fb40e99c4 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -108,11 +108,11 @@ "fiction-book-writing": { "name": "Fiction Book Writing", "id": "fiction-book-writing", - "version": "1.6.0", - "description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported.", + "version": "1.7.0", + "description": "Spec-Driven Development for novel and long-form fiction. 27 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.", "author": "Andreas Daumann", "repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing", - "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.6.0.zip", + "download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.7.0.zip", "homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing", "documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md", "license": "MIT", @@ -122,7 +122,7 @@ "provides": { "templates": 22, "commands": 27, - "scripts": 1 + "scripts": 2 }, "tags": [ "writing", @@ -140,7 +140,7 @@ "language-support" ], "created_at": "2026-04-09T08:00:00Z", - "updated_at": "2026-04-19T08:00:00Z" + "updated_at": "2026-04-27T08:00:00Z" }, "jira": { "name": "Jira Issue Tracking", From bd3ae9aaef3ea74818be30484e4c137f69683ecb Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:28:05 +0100 Subject: [PATCH 124/184] Add MarkItDown Document Converter extension to community catalog (#2390) --- README.md | 1 + extensions/catalog.community.json | 41 ++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 419e7f919a..8266fb2e4b 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ The following community-contributed extensions are available in [`catalog.commun | MAQA Jira Integration | Jira integration for MAQA — syncs Stories and Subtasks as features progress through the board | `integration` | Read+Write | [spec-kit-maqa-jira](https://github.com/GenieRobot/spec-kit-maqa-jira) | | MAQA Linear Integration | Linear integration for MAQA — syncs issues and sub-issues across workflow states as features progress | `integration` | Read+Write | [spec-kit-maqa-linear](https://github.com/GenieRobot/spec-kit-maqa-linear) | | MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) | +| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) | | Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) | | Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) | | MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 0c6e5cbfb3..27699b7113 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-24T14:00:00Z", + "updated_at": "2026-04-28T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1167,6 +1167,45 @@ "created_at": "2026-03-26T00:00:00Z", "updated_at": "2026-03-26T00:00:00Z" }, + "markitdown": { + "name": "MarkItDown Document Converter", + "id": "markitdown", + "description": "Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material in Spec Kit workflows.", + "author": "BenBtg", + "version": "1.0.0", + "download_url": "https://github.com/BenBtg/spec-kit-markitdown/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/BenBtg/spec-kit-markitdown", + "homepage": "https://github.com/BenBtg/spec-kit-markitdown", + "documentation": "https://github.com/BenBtg/spec-kit-markitdown/blob/main/README.md", + "changelog": "https://github.com/BenBtg/spec-kit-markitdown/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "markitdown", + "version": ">=0.1.0", + "required": true + } + ] + }, + "provides": { + "commands": 1, + "hooks": 0 + }, + "tags": [ + "markdown", + "pdf", + "document-conversion", + "reference-material", + "extraction" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-28T00:00:00Z", + "updated_at": "2026-04-28T00:00:00Z" + }, "memory-loader": { "name": "Memory Loader", "id": "memory-loader", From 56f9b95b0d06d6e29d082d747225cf119efa03ce Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:07:20 +0100 Subject: [PATCH 125/184] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 8b2b5d7bdc..6b0cedac44 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -955,7 +955,12 @@ "license": "MIT", "requires": { "speckit_version": ">=0.1.0", - "tools": ["m365 CLI (CLI for Microsoft 365)"] + "tools": [ + { + "name": "m365 CLI (CLI for Microsoft 365)", + "required": true + } + ] }, "provides": { "commands": 3, From fe9f19d5690003a434718cf13623a74c3251d08f Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:16:47 +0100 Subject: [PATCH 126/184] Potential fix for pull request finding "microsoft-365", "teams", "meetings", "transcripts", Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 6b0cedac44..0df41900cd 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -969,10 +969,8 @@ "tags": [ "microsoft-365", "teams", - "meetings", "transcripts", "collaboration", - "extraction", "summarization" ], "verified": false, From 719eef3ff1c712598248fda9569f0d45a0209663 Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:31:53 +0100 Subject: [PATCH 127/184] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 0df41900cd..be56c3747c 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -976,8 +976,8 @@ "verified": false, "downloads": 0, "stars": 0, - "created_at": "2026-04-27T00:00:00Z", - "updated_at": "2026-04-27T00:00:00Z" + "created_at": "2026-04-28T00:00:00Z", + "updated_at": "2026-04-28T00:00:00Z" }, "maqa": { "name": "MAQA — Multi-Agent & Quality Assurance", From 5b3ebabcaf74ff4755157f384030cdf564b8aeff Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:36:01 +0100 Subject: [PATCH 128/184] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e2fe7a1bd5..9831f48270 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ The following community-contributed extensions are available in [`catalog.commun | Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) | | Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) | | MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | -| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `docs` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) | +| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | 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) | From 7d0f670b8301c1148492add147b05350ff73d188 Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:40:27 +0100 Subject: [PATCH 129/184] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index be56c3747c..c2d33aad95 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -957,7 +957,7 @@ "speckit_version": ">=0.1.0", "tools": [ { - "name": "m365 CLI (CLI for Microsoft 365)", + "name": "m365", "required": true } ] From 047be2308c55aa86ef73667020656cc23250d2a8 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:50:23 -0500 Subject: [PATCH 130/184] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index c2d33aad95..3b9bec38c8 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -958,6 +958,7 @@ "tools": [ { "name": "m365", + "version": ">=0.0.0", "required": true } ] From ea92155b524c18d554828b68d8e335857f3c565e Mon Sep 17 00:00:00 2001 From: Ben Buttigieg <70525+BenBtg@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:55:12 +0100 Subject: [PATCH 131/184] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- extensions/catalog.community.json | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 3b9bec38c8..c2d33aad95 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -958,7 +958,6 @@ "tools": [ { "name": "m365", - "version": ">=0.0.0", "required": true } ] From bc3409e340eaa7705489988290eece5f74b55099 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:52:25 -0500 Subject: [PATCH 132/184] chore: release 0.8.2, begin 0.8.3.dev0 development (#2397) * chore: bump version to 0.8.2 * chore: begin 0.8.3.dev0 development * Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 934f7962ff..8202610ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.8.2] - 2026-04-28 + +### Changed + +- Add MarkItDown Document Converter extension to community catalog (#2390) +- feat: Speckit preset fiction book v1.7 - Support for RAG (Chroma DB) offline semantic search (#2367) +- fix(extensions): use explicit UTF-8 encoding when reading manifest YAML (#2370) +- catalog: add m365 community extension +- docs: replace deprecated --ai flag with --integration in all documentation (#2359) +- feat(extensions,presets): authenticate GitHub-hosted catalog and download requests with GITHUB_TOKEN/GH_TOKEN (#2331) +- Update extensify to v1.1.0 in community catalog (#2337) +- feat(init): deprecate --no-git flag, gate deprecations at v0.10.0 (#2357) +- Add Spec Orchestrator extension to community catalog (#2350) +- chore: release 0.8.1, begin 0.8.2.dev0 development (#2356) + ## [0.8.1] - 2026-04-24 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 23a0344faa..ffd0a0aaa4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.8.2.dev0" +version = "0.8.3.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 16aa57fce4a4ba2e378f30f379e2c74f71ffdb04 Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Tue, 28 Apr 2026 22:34:53 +0200 Subject: [PATCH 133/184] Add isaqb-architecture-governance to community catalog (#2385) Co-authored-by: Your Name --- docs/community/presets.md | 1 + presets/catalog.community.json | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index c48f9a3e5b..0bb7a7ab42 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -12,6 +12,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | | Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | +| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) | | Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) | | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 8fb40e99c4..7031652bfd 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-15T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "aide-in-place": { @@ -142,6 +142,34 @@ "created_at": "2026-04-09T08:00:00Z", "updated_at": "2026-04-27T08:00:00Z" }, + "isaqb-architecture-governance": { + "name": "iSAQB Architecture Governance", + "id": "isaqb-architecture-governance", + "version": "0.1.0", + "description": "Adds general iSAQB/CPSA-F and arc42 architecture governance, including views, quality scenarios, ADRs, risks, and technical debt.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.1.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 13, + "commands": 3 + }, + "tags": [ + "architecture", + "governance", + "isaqb", + "arc42", + "adr" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, "jira": { "name": "Jira Issue Tracking", "id": "jira", From 38f99e83813ba725b0f147d11017516c7d730bcd Mon Sep 17 00:00:00 2001 From: NaviaSamal Date: Tue, 28 Apr 2026 15:02:42 -0700 Subject: [PATCH 134/184] feat: add threatmodel extension to community catalog (#2369) * feat: add threatmodel extension to community catalog * update timestamp for catalogue freshness * update timestamp for catalogue freshness * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Update README.md update readme.md with spec-kit-threatmodel --------- Co-authored-by: Samal Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9831f48270..59d2fd5dec 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,7 @@ The following community-contributed extensions are available in [`catalog.commun | Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | +| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) | | 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-) | | Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index c2d33aad95..9ce86110b5 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-28T00:00:00Z", + "updated_at": "2026-04-28T12:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -2392,6 +2392,38 @@ "created_at": "2026-04-10T00:00:00Z", "updated_at": "2026-04-10T00:00:00Z" }, + "threatmodel": { + "name": "OWASP LLM Threat Model", + "id": "threatmodel", + "description": "OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts", + "author": "NaviaSamal", + "version": "1.0.0", + "download_url": "https://github.com/NaviaSamal/spec-kit-threatmodel/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/NaviaSamal/spec-kit-threatmodel", + "homepage": "https://github.com/NaviaSamal/spec-kit-threatmodel", + "documentation": "https://github.com/NaviaSamal/spec-kit-threatmodel/blob/main/README.md", + "changelog": "https://github.com/NaviaSamal/spec-kit-threatmodel/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.0" + }, + "provides": { + "commands": 1, + "hooks": 1 + }, + "tags": [ + "security", + "owasp", + "threat-model", + "llm", + "analysis" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-25T00:00:00Z", + "updated_at": "2026-04-25T00:00:00Z" + }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", From 9483e5cb1fbdb97f7c806a72f159afe762b5e04e Mon Sep 17 00:00:00 2001 From: Leonardo Nascimento Date: Tue, 28 Apr 2026 23:14:21 +0100 Subject: [PATCH 135/184] chore(catalog): bump v-model extension to v0.6.0 (#2399) Update v-model extension entry in community catalog to reflect the v0.6.0 release: https://github.com/leocamello/spec-kit-v-model/releases/tag/v0.6.0 Highlights of v0.6.0: - Domain Overlay Architecture (9 overlay manifests; automotive, medical, aerospace, general) - ID Lifecycle Model (Proposed -> Active -> Deprecated -> Removed) - Standards enrichment across all 11 commands (IEEE 1012:2016, ISO 25010:2023, ISO 42030:2019, ISO 12207:2017, IEEE 1016, IEEE 29148, ISO 29119-4, ISO 14971, DO-178C, ARP4761A, INCOSE SE Handbook) - Aerospace DO-178C support: Flight Warning Computer DAL-A golden fixture - Test infrastructure: fixtures reorganized; +11 LLM-as-judge evals (42 -> 53) Command count remains 14 (no new commands added in this release). Stars updated to live count (21) from GitHub API. --- extensions/catalog.community.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 9ce86110b5..b0d4aa5b8e 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2429,8 +2429,8 @@ "id": "v-model", "description": "Enforces V-Model paired generation of development specs and test specs with full traceability.", "author": "leocamello", - "version": "0.5.0", - "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.5.0.zip", + "version": "0.6.0", + "download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.6.0.zip", "repository": "https://github.com/leocamello/spec-kit-v-model", "homepage": "https://github.com/leocamello/spec-kit-v-model", "documentation": "https://github.com/leocamello/spec-kit-v-model/blob/main/README.md", @@ -2452,9 +2452,9 @@ ], "verified": false, "downloads": 0, - "stars": 0, + "stars": 21, "created_at": "2026-02-20T00:00:00Z", - "updated_at": "2026-04-06T00:00:00Z" + "updated_at": "2026-04-25T00:00:00Z" }, "verify": { "name": "Verify Extension", From 9cf3151a728f1349850d0d1ebb46bca5d9337ac5 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Wed, 29 Apr 2026 19:16:01 +0700 Subject: [PATCH 136/184] update security review extension catalog to v1.3.0 (#2374) * chore: update security review catalog metadata * fix: sync security review catalog with v1.3.0 * chore: refresh community catalog timestamp * fix: update author information for Security Review catalog entry * fix: correct author name format in Security Review catalog entry * chore: refresh community catalog timestamps * chore: reapply catalog formatting * chore: align catalog formatting with main --- README.md | 2 +- extensions/catalog.community.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 59d2fd5dec..6450e35601 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ The following community-contributed extensions are available in [`catalog.commun | Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) | | Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) | | SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) | -| Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | +| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) | | SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) | | Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) | | Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index b0d4aa5b8e..6d0537a0c0 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-28T12:00:00Z", + "updated_at": "2026-04-29T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1929,10 +1929,10 @@ "security-review": { "name": "Security Review", "id": "security-review", - "description": "Comprehensive security audit of codebases using AI-powered DevSecOps analysis", + "description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews", "author": "DyanGalih", - "version": "1.1.1", - "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.1.1.zip", + "version": "1.3.0", + "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.3.0.zip", "repository": "https://github.com/DyanGalih/spec-kit-security-review", "homepage": "https://github.com/DyanGalih/spec-kit-security-review", "documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md", @@ -1942,7 +1942,7 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 3, + "commands": 6, "hooks": 0 }, "tags": [ @@ -1956,7 +1956,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-03T03:24:03Z", - "updated_at": "2026-04-03T04:15:00Z" + "updated_at": "2026-04-29T00:00:00Z" }, "sf": { "name": "SFSpeckit — Salesforce Spec-Driven Development", From 1049e17a433a91a97e171a421cbc3deb2a13a020 Mon Sep 17 00:00:00 2001 From: Adrian Osorio Blanchard Date: Wed, 29 Apr 2026 08:24:30 -0400 Subject: [PATCH 137/184] feat: add catalog discovery CLI commands (#2360) * feat: add catalog discovery CLI commands * fix: address second Copilot review * fix: address third Copilot review * fix: align catalog remove with displayed order * fix: route local catalog config errors to local guidance * fix: address integration catalog review feedback * fix: accept numeric string catalog priorities * fix: align catalog remove with visible entries * fix: preserve invalid catalog root validation * fix: include invalid catalog priority value * fix: preserve falsy catalog root validation * fix: clarify integration catalog guidance * fix: align integration catalog list and remove * fix: align integration catalog edge cases * fix: clarify catalog error guidance tests * fix: clarify integration catalog edge cases * fix: harden integration catalog removal * fix: validate integration state before catalog search * fix: reject empty integration catalog URL * fix: allow catalog remove to clean non-string URLs * fix: address catalog env and priority review * fix: align catalog source display names * fix: align catalog fallback names --- src/specify_cli/__init__.py | 315 +++++++ src/specify_cli/integrations/catalog.py | 353 ++++++- tests/integrations/test_cli.py | 547 +++++++++++ .../integrations/test_integration_catalog.py | 875 +++++++++++++++++- 4 files changed, 2070 insertions(+), 20 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d5f5aba2d5..f5e117beef 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1886,6 +1886,13 @@ def get_speckit_version() -> str: ) app.add_typer(integration_app, name="integration") +integration_catalog_app = typer.Typer( + name="catalog", + help="Manage integration catalog sources", + add_completion=False, +) +integration_app.add_typer(integration_catalog_app, name="catalog") + INTEGRATION_JSON = ".specify/integration.json" @@ -2535,6 +2542,314 @@ def integration_upgrade( console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully") +# ===== 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. + + +def _require_specify_project() -> Path: + """Return the current project root if it is a spec-kit project, else exit.""" + project_root = Path.cwd() + if not (project_root / ".specify").exists(): + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + return project_root + + +@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 .integrations import INTEGRATION_REGISTRY + from .integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + IntegrationValidationError, + ) + + project_root = _require_specify_project() + integration_config = _read_integration_json(project_root) + installed_key = integration_config.get("integration") + 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 .integrations import INTEGRATION_REGISTRY + from .integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + IntegrationValidationError, + ) + + project_root = _require_specify_project() + catalog = IntegrationCatalog(project_root) + installed_key = _read_integration_json(project_root).get("integration") + + 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 .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + 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 .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + 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 .integrations.catalog import IntegrationCatalog, IntegrationCatalogError + + 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") + + # ===== Preset Commands ===== diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py index 2faa69ae96..1b449af682 100644 --- a/src/specify_cli/integrations/catalog.py +++ b/src/specify_cli/integrations/catalog.py @@ -16,7 +16,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import yaml from packaging import version as pkg_version @@ -30,6 +30,10 @@ class IntegrationCatalogError(Exception): """Raised when a catalog operation fails.""" +class IntegrationValidationError(IntegrationCatalogError): + """Validation error for catalog config or catalog management operations.""" + + class IntegrationDescriptorError(Exception): """Raised when an integration.yml descriptor is invalid.""" @@ -96,28 +100,36 @@ def _load_catalog_config( Returns None when the file does not exist. Raises: - IntegrationCatalogError: on invalid content + IntegrationValidationError: on any local-config / YAML problem + (parse failures, wrong shape, missing/invalid fields, + invalid catalog URLs, etc.). This is a subclass of + :class:`IntegrationCatalogError`, so any caller that already + catches ``IntegrationCatalogError`` keeps working — but + callers that want to distinguish *local config* problems + from *remote/network* problems can match the subclass. """ if not config_path.exists(): return None try: - data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) except (yaml.YAMLError, OSError, UnicodeError) as exc: - raise IntegrationCatalogError( + raise IntegrationValidationError( f"Failed to read catalog config {config_path}: {exc}" - ) + ) from exc + if data is None: + data = {} if not isinstance(data, dict): - raise IntegrationCatalogError( + raise IntegrationValidationError( f"Invalid catalog config {config_path}: expected a YAML mapping at the root" ) catalogs_data = data.get("catalogs", []) if not isinstance(catalogs_data, list): - raise IntegrationCatalogError( - f"Invalid catalog config: 'catalogs' must be a list, " + raise IntegrationValidationError( + f"Invalid catalog config {config_path}: 'catalogs' must be a list, " f"got {type(catalogs_data).__name__}" ) if not catalogs_data: - raise IntegrationCatalogError( + raise IntegrationValidationError( f"Catalog config {config_path} exists but contains no 'catalogs' entries. " f"Remove the file to use built-in defaults, or add valid catalog entries." ) @@ -125,31 +137,52 @@ def _load_catalog_config( skipped: List[int] = [] for idx, item in enumerate(catalogs_data): if not isinstance(item, dict): - raise IntegrationCatalogError( - f"Invalid catalog entry at index {idx}: " + raise IntegrationValidationError( + f"Invalid catalog config {config_path}: catalog entry at index {idx}: " f"expected a mapping, got {type(item).__name__}" ) url = str(item.get("url", "")).strip() if not url: skipped.append(idx) continue - self._validate_catalog_url(url) try: - priority = int(item.get("priority", idx + 1)) + self._validate_catalog_url(url) + except IntegrationCatalogError as exc: + # ``_validate_catalog_url`` raises the base class for direct + # callers (e.g. ``add_catalog`` validating user input); when + # the bad URL came from a local config file, surface it as a + # validation error so CLI handlers can route it accordingly. + raise IntegrationValidationError( + f"Invalid catalog URL in {config_path} at index {idx}: {exc}" + ) from exc + raw_priority = item.get("priority", idx + 1) + if isinstance(raw_priority, bool): + raise IntegrationValidationError( + f"Invalid catalog config {config_path}: " + f"Invalid priority for catalog '{item.get('name', idx + 1)}': " + f"expected integer, got {raw_priority!r}" + ) + try: + priority = int(raw_priority) except (TypeError, ValueError): - raise IntegrationCatalogError( + raise IntegrationValidationError( + f"Invalid catalog config {config_path}: " f"Invalid priority for catalog '{item.get('name', idx + 1)}': " - f"expected integer, got {item.get('priority')!r}" + f"expected integer, got {raw_priority!r}" ) raw_install = item.get("install_allowed", False) if isinstance(raw_install, str): install_allowed = raw_install.strip().lower() in ("true", "yes", "1") else: install_allowed = bool(raw_install) + raw_name = item.get("name") + name = str(raw_name).strip() if raw_name is not None else "" + if not name: + name = f"catalog-{len(entries) + 1}" entries.append( IntegrationCatalogEntry( url=url, - name=str(item.get("name", f"catalog-{idx + 1}")), + name=name, priority=priority, install_allowed=install_allowed, description=str(item.get("description", "")), @@ -157,7 +190,7 @@ def _load_catalog_config( ) entries.sort(key=lambda e: e.priority) if not entries: - raise IntegrationCatalogError( + raise IntegrationValidationError( f"Catalog config {config_path} contains {len(catalogs_data)} " f"entries but none have valid URLs (entries at indices {skipped} " f"were skipped). Each catalog entry must have a 'url' field." @@ -196,12 +229,12 @@ def get_active_catalogs(self) -> List[IntegrationCatalogEntry]: ) ] - project_cfg = self.project_root / ".specify" / "integration-catalogs.yml" + project_cfg = self.project_root / ".specify" / self.CONFIG_FILENAME catalogs = self._load_catalog_config(project_cfg) if catalogs is not None: return catalogs - user_cfg = Path.home() / ".specify" / "integration-catalogs.yml" + user_cfg = Path.home() / ".specify" / self.CONFIG_FILENAME catalogs = self._load_catalog_config(user_cfg) if catalogs is not None: return catalogs @@ -408,6 +441,288 @@ def clear_cache(self) -> None: for f in self.cache_dir.glob(pattern): f.unlink(missing_ok=True) + # -- Catalog-source management ---------------------------------------- + + CONFIG_FILENAME = "integration-catalogs.yml" + + def get_catalog_configs(self) -> List[Dict[str, Any]]: + """Return the active catalog stack as a list of dicts. + + Thin adapter over :meth:`get_active_catalogs` that yields plain dicts + suitable for CLI rendering and JSON-like consumers. + """ + return [ + { + "name": e.name, + "url": e.url, + "priority": e.priority, + "install_allowed": e.install_allowed, + "description": e.description, + } + for e in self.get_active_catalogs() + ] + + def get_project_catalog_configs(self) -> Optional[List[Dict[str, Any]]]: + """Return removable project-level catalog config entries, if configured.""" + config_path = self.project_root / ".specify" / self.CONFIG_FILENAME + entries = self._load_catalog_config(config_path) + if entries is None: + return None + return [ + { + "name": e.name, + "url": e.url, + "priority": e.priority, + "install_allowed": e.install_allowed, + "description": e.description, + } + for e in entries + ] + + def add_catalog(self, url: str, name: Optional[str] = None) -> None: + """Add a catalog source to the project-level config file. + + The URL is normalized (whitespace stripped) and validated before being + written. Duplicate URLs are rejected, including near-duplicates that + differ only by surrounding whitespace. Priority is derived as + ``max(existing) + 1`` so the new entry sorts last in the resolution + order unless the user edits the file manually. + """ + url = url.strip() + if not url: + raise IntegrationValidationError("Catalog URL must be non-empty.") + self._validate_catalog_url(url) + config_path = self.project_root / ".specify" / self.CONFIG_FILENAME + + data: Dict[str, Any] = {"catalogs": []} + if config_path.exists(): + try: + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise IntegrationValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) from exc + if raw is None: + raw = {} + if not isinstance(raw, dict): + raise IntegrationValidationError( + f"Catalog config file {config_path} is corrupted " + "(expected a mapping)." + ) + data = raw + + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise IntegrationValidationError( + f"Catalog config {config_path} has invalid 'catalogs' value: " + "must be a list." + ) + + # Validate each existing entry before mutating anything. Fail fast so + # we don't silently preserve a corrupt sibling entry or derive a new + # priority from a bogus value. + existing_priorities: List[int] = [] + valid_catalog_count = 0 + for idx, cat in enumerate(catalogs): + if not isinstance(cat, dict): + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: " + f"expected a mapping, got {type(cat).__name__}." + ) + existing_url = str(cat.get("url", "")).strip() + if not existing_url: + continue + # Re-run the same URL validation used when loading, so a corrupt + # entry surfaces here instead of at the next `integration` call. + try: + self._validate_catalog_url(existing_url) + except IntegrationCatalogError as exc: + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: {exc}" + ) from exc + if existing_url == url: + raise IntegrationValidationError( + f"Catalog URL already configured: {url}" + ) + valid_catalog_count += 1 + if "priority" in cat: + raw_priority = cat.get("priority") + if isinstance(raw_priority, bool): + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: " + f"'priority' must be an integer, got " + f"{type(raw_priority).__name__}." + ) + try: + normalized_priority = int(raw_priority) + except (TypeError, ValueError): + raise IntegrationValidationError( + f"Invalid catalog entry at index {idx} in {config_path}: " + f"'priority' must be an integer, got " + f"{raw_priority!r}." + ) from None + existing_priorities.append(normalized_priority) + else: + # Match `_load_catalog_config()`'s defaulting rule so the new + # entry still sorts after implicit-priority siblings. + existing_priorities.append(idx + 1) + + max_priority = max(existing_priorities, default=0) + normalized_name = str(name).strip() if name is not None else "" + generated_name = f"catalog-{valid_catalog_count + 1}" + catalogs.append( + { + "name": normalized_name or generated_name, + "url": url, + "priority": max_priority + 1, + "install_allowed": True, + "description": "", + } + ) + data["catalogs"] = catalogs + + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump( + data, + f, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + + def remove_catalog(self, index: int) -> str: + """Remove a catalog source by 0-based index. + + ``index`` is interpreted in the same display order shown by + ``integration catalog list`` (i.e. sorted ascending by priority, + with missing priority defaulting to ``yaml_index + 1``, matching + ``_load_catalog_config()``). This way, the index a user sees in + ``catalog list`` is the index they pass to ``catalog remove``, + even if the underlying YAML lists entries in a different order + from how they sort by priority. + + Returns the removed catalog's name. + """ + config_path = self.project_root / ".specify" / self.CONFIG_FILENAME + if not config_path.exists(): + raise IntegrationValidationError("No catalog config file found.") + + try: + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeError) as exc: + raise IntegrationValidationError( + f"Failed to read catalog config {config_path}: {exc}" + ) from exc + if data is None: + data = {} + if not isinstance(data, dict): + raise IntegrationValidationError( + f"Catalog config file {config_path} is corrupted " + "(expected a mapping)." + ) + + catalogs = data.get("catalogs", []) + if not isinstance(catalogs, list): + raise IntegrationValidationError( + f"Catalog config {config_path} has invalid 'catalogs' value: " + "must be a list." + ) + + if not catalogs: + # An empty list is the kind of state that only happens if the + # user hand-edited the file; our own `remove_catalog` deletes + # the file when the last entry is popped. Surface a clear + # message instead of `out of range (0--1)`. + raise IntegrationValidationError( + "Catalog config contains no catalog entries." + ) + + # Map displayed index -> raw YAML index using the same priority + # defaulting as ``_load_catalog_config``. We deliberately stay + # tolerant here (no new validation errors) because the goal is + # only to mirror the order shown by ``catalog list``; entries + # that ``_load_catalog_config`` would have rejected outright + # would have failed ``catalog list`` already. + def _is_removable_catalog_entry(item: Any) -> bool: + if not isinstance(item, dict): + return False + raw_url = item.get("url") + if raw_url is None: + return False + return bool(str(raw_url).strip()) + + priority_pairs: List[Tuple[int, int]] = [] + for yaml_idx, item in enumerate(catalogs): + if not _is_removable_catalog_entry(item): + continue + + raw_priority = item.get("priority", yaml_idx + 1) + if isinstance(raw_priority, bool): + priority = yaml_idx + 1 + else: + try: + priority = int(raw_priority) + except (TypeError, ValueError): + priority = yaml_idx + 1 + priority_pairs.append((priority, yaml_idx)) + if not priority_pairs: + raise IntegrationValidationError( + "Catalog config contains no removable catalog entries." + ) + # Stable sort: ties keep their YAML order, matching list-view ordering. + priority_pairs.sort(key=lambda p: p[0]) + display_order: List[int] = [yaml_idx for _, yaml_idx in priority_pairs] + + if index < 0 or index >= len(display_order): + raise IntegrationValidationError( + f"Catalog index {index} out of range (0-{len(display_order) - 1})." + ) + + target_yaml_idx = display_order[index] + removed = catalogs.pop(target_yaml_idx) + + if any(_is_removable_catalog_entry(item) for item in catalogs): + data["catalogs"] = catalogs + with open(config_path, "w", encoding="utf-8") as f: + yaml.dump( + data, + f, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + else: + # Removing the final entry: delete the config file rather than + # leaving behind an empty `catalogs:` list. `_load_catalog_config` + # treats an empty list as an error, so leaving the file would + # break every subsequent `integration` command until the user + # manually deletes `.specify/integration-catalogs.yml`. + # Deleting the file lets the project fall back to built-in + # defaults, which matches the behavior before any + # `catalog add` was ever run. + try: + config_path.unlink(missing_ok=True) + except OSError as exc: + raise IntegrationValidationError( + f"Failed to delete catalog config {config_path}: {exc}" + ) from exc + + fallback_name = f"catalog-{index + 1}" + if isinstance(removed, dict): + removed_name = removed.get("name") + if removed_name is not None: + normalized_name = str(removed_name).strip() + if normalized_name: + return normalized_name + + removed_url = removed.get("url") + if removed_url is not None: + normalized_url = str(removed_url).strip() + if normalized_url: + return normalized_url + return fallback_name + # --------------------------------------------------------------------------- # IntegrationDescriptor (integration.yml) diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index df48323ed2..60e51a5fb9 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -628,3 +628,550 @@ def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path): assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan" assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template" assert "__SPECKIT_COMMAND_" not in content + + +class TestIntegrationCatalogDiscoveryCLI: + """End-to-end CLI tests for `integration search`, `info`, and `catalog …`. + + All tests patch `IntegrationCatalog._get_merged_integrations` so no network + or on-disk cache is touched. Adds #2344 coverage without affecting any + existing integration install/switch/uninstall/upgrade behavior. + """ + + FAKE_INTEGRATIONS = [ + { + "id": "acme-coder", + "name": "Acme Coder", + "version": "2.0.0", + "description": "Community integration for Acme Coder", + "author": "acme-org", + "tags": ["cli", "acme"], + "_catalog_name": "community", + "_install_allowed": False, + }, + { + "id": "stellar-agent", + "name": "Stellar Agent", + "version": "1.3.0", + "description": "First-party Stellar agent integration", + "author": "stellar-labs", + "tags": ["ide"], + "_catalog_name": "default", + "_install_allowed": True, + }, + ] + + def _make_project(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + (project / ".specify").mkdir() + return project + + def _patch_catalog(self, monkeypatch, integrations=None): + """Return a stubbed `_get_merged_integrations` that yields *integrations*.""" + from specify_cli.integrations.catalog import IntegrationCatalog + + data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS) + + def fake_merged(self, force_refresh=False): + return data + + monkeypatch.setattr(IntegrationCatalog, "_get_merged_integrations", fake_merged) + + def _invoke(self, argv, cwd): + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + old = os.getcwd() + try: + os.chdir(cwd) + return runner.invoke(app, argv, catch_exceptions=False) + finally: + os.chdir(old) + + # -- Project guard ----------------------------------------------------- + + def test_search_requires_specify_project(self, tmp_path): + project = tmp_path / "bare" + project.mkdir() + result = self._invoke(["integration", "search"], project) + assert result.exit_code == 1 + assert "Not a spec-kit project" in result.output + + def test_catalog_list_requires_specify_project(self, tmp_path): + project = tmp_path / "bare" + project.mkdir() + result = self._invoke(["integration", "catalog", "list"], project) + assert result.exit_code == 1 + assert "Not a spec-kit project" in result.output + + # -- search ------------------------------------------------------------ + + def test_search_lists_all(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "Found 2 integration(s)" in result.output + assert "acme-coder" in result.output + assert "stellar-agent" in result.output + assert "specify integration install stellar-agent" not in normalized_output + assert "Only built-in integration IDs can be installed" in normalized_output + + def test_search_validates_integration_json_before_catalog_lookup( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + (project / ".specify" / "integration.json").write_text( + "{bad json\n", encoding="utf-8" + ) + + from specify_cli.integrations.catalog import IntegrationCatalog + + def fail_search(self, **kwargs): + raise AssertionError("catalog search should not be called") + + monkeypatch.setattr(IntegrationCatalog, "search", fail_search) + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1 + assert "contains invalid JSON" in normalized_output + assert "integration.json" in normalized_output + + def test_search_filters_by_tag(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke(["integration", "search", "--tag", "acme"], project) + assert result.exit_code == 0, result.output + assert "Found 1 integration(s)" in result.output + assert "acme-coder" in result.output + assert "stellar-agent" not in result.output + + def test_search_filters_by_author(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "search", "--author", "stellar-labs"], project + ) + assert result.exit_code == 0, result.output + assert "Found 1 integration(s)" in result.output + assert "stellar-agent" in result.output + + def test_search_no_match_hint(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "search", "--tag", "nope"], project + ) + assert result.exit_code == 0, result.output + assert "No integrations found" in result.output + assert "specify integration search" in result.output + + def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke(["integration", "search", "acme"], project) + assert result.exit_code == 0, result.output + # acme-coder is flagged _install_allowed=False, so we should warn + assert "Not directly installable" in result.output + + # -- info -------------------------------------------------------------- + + def test_info_found(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "info", "stellar-agent"], project + ) + assert result.exit_code == 0, result.output + assert "Stellar Agent" in result.output + assert "stellar-agent" in result.output + assert "v1.3.0" in result.output + + def test_info_not_found(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke( + ["integration", "info", "does-not-exist"], project + ) + assert result.exit_code == 1 + assert "not found" in result.output + + def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + # Empty catalog, but copilot is a registered built-in. + self._patch_catalog(monkeypatch, integrations=[]) + result = self._invoke(["integration", "info", "copilot"], project) + assert result.exit_code == 0, result.output + assert "Built-in integration" in result.output + + # -- validation vs network guidance ------------------------------------ + + def test_search_local_config_error_shows_local_config_tip( + self, tmp_path, monkeypatch + ): + """`integration search` must point at .specify/integration-catalogs.yml + for local-config errors (not the generic 'temporarily unavailable').""" + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + # Corrupt YAML to drive _load_catalog_config -> IntegrationValidationError. + cfg = project / ".specify" / "integration-catalogs.yml" + invalid_yaml = "catalogs:\n - [bad\n" + cfg.write_text(invalid_yaml, encoding="utf-8") + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "configuration file path shown above" in normalized_output + assert ".specify/integration-catalogs.yml" in normalized_output + assert "~/.specify/integration-catalogs.yml" in normalized_output + assert "temporarily unavailable" not in normalized_output + + def test_search_invalid_env_catalog_url_shows_env_tip( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "http://insecure.example.com/catalog.json", + ) + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "SPECKIT_INTEGRATION_CATALOG_URL environment variable" in normalized_output + assert "unset it to use the configured catalog files" in normalized_output + assert ".specify/integration-catalogs.yml" in normalized_output + assert "~/.specify/integration-catalogs.yml" in normalized_output + assert "temporarily unavailable" not in normalized_output + + def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", " ") + + from specify_cli.integrations.catalog import ( + IntegrationCatalog, + IntegrationCatalogError, + ) + + def fail_search(self, **kwargs): + raise IntegrationCatalogError("catalog offline") + + monkeypatch.setattr(IntegrationCatalog, "search", fail_search) + + result = self._invoke(["integration", "search"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "temporarily unavailable" in normalized_output + assert ( + "SPECKIT_INTEGRATION_CATALOG_URL environment variable" + not in normalized_output + ) + + def test_info_unknown_with_local_config_error_shows_local_config_tip( + self, tmp_path, monkeypatch + ): + """`integration info ` falls back to the catalog-error branch + and must show local-config guidance, not 'Try again when online'.""" + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + cfg = project / ".specify" / "integration-catalogs.yml" + invalid_yaml = "catalogs:\n - [bad\n" + cfg.write_text(invalid_yaml, encoding="utf-8") + + result = self._invoke( + ["integration", "info", "definitely-not-real"], project + ) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "configuration file path shown above" in normalized_output + assert ".specify/integration-catalogs.yml" in normalized_output + assert "~/.specify/integration-catalogs.yml" in normalized_output + assert "Try again when online" not in normalized_output + + def test_info_unknown_with_invalid_env_catalog_url_shows_env_tip( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "http://insecure.example.com/catalog.json", + ) + + result = self._invoke( + ["integration", "info", "definitely-not-real"], project + ) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 1, result.output + assert "SPECKIT_INTEGRATION_CATALOG_URL" in normalized_output + assert "unset it to use the configured catalog files" in normalized_output + assert "Try again when online" not in normalized_output + + # -- catalog list / add / remove --------------------------------------- + + def test_catalog_list_shows_builtin_defaults(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + result = self._invoke(["integration", "catalog", "list"], project) + assert result.exit_code == 0, result.output + assert "Integration Catalog Sources" in result.output + assert "No project-level catalog sources configured" in result.output + assert "Active catalog sources" in result.output + assert "non-removable" in result.output + assert "default" in result.output + assert "community" in result.output + # Built-in defaults are active, but not removable project entries. + assert "[0]" not in result.output + assert "[1]" not in result.output + + def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + + add_result = self._invoke( + [ + "integration", + "catalog", + "add", + "https://new.example.com/catalog.json", + "--name", + "mine", + ], + project, + ) + assert add_result.exit_code == 0, add_result.output + assert "Catalog source added" in add_result.output + + cfg_path = project / ".specify" / "integration-catalogs.yml" + assert cfg_path.exists() + + list_result = self._invoke(["integration", "catalog", "list"], project) + assert list_result.exit_code == 0, list_result.output + assert "Project catalog sources" in list_result.output + assert "[0]" in list_result.output + assert "mine" in list_result.output + assert "default" not in list_result.output + assert "community" not in list_result.output + + remove_result = self._invoke( + ["integration", "catalog", "remove", "0"], project + ) + assert remove_result.exit_code == 0, remove_result.output + assert "'mine' removed" in remove_result.output + + def test_catalog_list_normalizes_blank_project_catalog_names( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + cfg_path = project / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://null-name.example.com/catalog.json", + "name": None, + }, + { + "url": "https://blank-name.example.com/catalog.json", + "name": " ", + }, + ] + } + ), + encoding="utf-8", + ) + + result = self._invoke(["integration", "catalog", "list"], project) + normalized_output = _normalize_cli_output(result.output) + + assert result.exit_code == 0, result.output + assert "[0] catalog-1" in normalized_output + assert "[1] catalog-2" in normalized_output + assert "None" not in normalized_output + + def test_catalog_list_env_override_supersedes_project_config( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv( + "SPECKIT_INTEGRATION_CATALOG_URL", + "https://env.example.com/catalog.json", + ) + cfg_path = project / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://project.example.com/catalog.json", + "name": "project", + "priority": 1, + } + ] + } + ), + encoding="utf-8", + ) + + result = self._invoke(["integration", "catalog", "list"], project) + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + assert "SPECKIT_INTEGRATION_CATALOG_URL is set" in normalized_output + assert "supersedes configured catalog files" in normalized_output + assert "non-removable" in normalized_output + assert "https://env.example.com/catalog.json" in normalized_output + assert "https://project.example.com/catalog.json" not in normalized_output + assert "[0]" not in normalized_output + + def test_catalog_add_strips_whitespace_in_success_output_and_storage( + self, tmp_path, monkeypatch + ): + """Surrounding whitespace in the URL must not appear in the success + message or be persisted to the YAML config.""" + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + + padded_url = " https://padded.example.com/catalog.json " + clean_url = "https://padded.example.com/catalog.json" + + add_result = self._invoke( + [ + "integration", + "catalog", + "add", + padded_url, + "--name", + "padded", + ], + project, + ) + assert add_result.exit_code == 0, add_result.output + assert clean_url in add_result.output + assert padded_url not in add_result.output + + cfg_path = project / ".specify" / "integration-catalogs.yml" + import yaml as _yaml + data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + urls = [c["url"] for c in data["catalogs"]] + assert clean_url in urls + assert padded_url not in urls + + def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + result = self._invoke( + [ + "integration", + "catalog", + "add", + "http://insecure.example.com/catalog.json", + ], + project, + ) + assert result.exit_code == 1 + assert "HTTPS" in result.output + + def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + url = "https://dup.example.com/catalog.json" + first = self._invoke( + ["integration", "catalog", "add", url], project + ) + assert first.exit_code == 0, first.output + second = self._invoke( + ["integration", "catalog", "add", url], project + ) + assert second.exit_code == 1 + assert "already configured" in second.output + + def test_catalog_remove_out_of_range(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + # Need a config file for remove to attempt an index lookup + self._invoke( + [ + "integration", + "catalog", + "add", + "https://only.example.com/catalog.json", + ], + project, + ) + result = self._invoke( + ["integration", "catalog", "remove", "9"], project + ) + assert result.exit_code == 1 + assert "out of range" in result.output + + def test_catalog_remove_without_config(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + result = self._invoke( + ["integration", "catalog", "remove", "0"], project + ) + assert result.exit_code == 1 + assert "No catalog config" in result.output + + def test_catalog_remove_final_entry_restores_defaults( + self, tmp_path, monkeypatch + ): + """End-to-end: add → remove-last-entry → list should not error. + + Regression for the flow where a user adds a catalog, removes it, then + runs any follow-up integration command. Without the fix the config + file would be left as `catalogs: []` and every subsequent + `integration` call would fail with "contains no 'catalogs' entries". + """ + project = self._make_project(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + + add = self._invoke( + [ + "integration", + "catalog", + "add", + "https://only.example.com/catalog.json", + "--name", + "only", + ], + project, + ) + assert add.exit_code == 0, add.output + + remove = self._invoke( + ["integration", "catalog", "remove", "0"], project + ) + assert remove.exit_code == 0, remove.output + assert "'only' removed" in remove.output + + cfg_path = project / ".specify" / "integration-catalogs.yml" + assert not cfg_path.exists(), ( + "config file should be deleted when the final catalog is removed" + ) + + # Follow-up command must succeed and show the built-in defaults, + # not error out on "contains no 'catalogs' entries". + listing = self._invoke(["integration", "catalog", "list"], project) + assert listing.exit_code == 0, listing.output + assert "default" in listing.output + assert "community" in listing.output diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 6d82a6c390..6c55ae4ebc 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -12,6 +12,7 @@ IntegrationCatalogError, IntegrationDescriptor, IntegrationDescriptorError, + IntegrationValidationError, ) @@ -115,8 +116,45 @@ def test_empty_config_raises(self, tmp_path): cfg = specify / "integration-catalogs.yml" cfg.write_text(yaml.dump({"catalogs": []})) cat = IntegrationCatalog(tmp_path) - with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"): + with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries") as exc_info: cat.get_active_catalogs() + assert str(cfg) in str(exc_info.value) + + def test_empty_config_file_raises_no_catalogs(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + specify = tmp_path / ".specify" + specify.mkdir() + cfg = specify / "integration-catalogs.yml" + cfg.write_text("", encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="no 'catalogs' entries" + ) as exc_info: + cat.get_active_catalogs() + assert str(cfg) in str(exc_info.value) + + @pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"]) + def test_load_catalog_config_rejects_falsy_non_mapping_roots( + self, tmp_path, monkeypatch, config_content + ): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + specify = tmp_path / ".specify" + specify.mkdir() + cfg = specify / "integration-catalogs.yml" + cfg.write_text(config_content, encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, + match="expected a YAML mapping at the root", + ) as exc_info: + cat.get_active_catalogs() + assert str(cfg) in str(exc_info.value) # --------------------------------------------------------------------------- @@ -654,3 +692,838 @@ def test_upgrade_no_manifest(self, tmp_path): os.chdir(old) assert result.exit_code == 0 assert "Nothing to upgrade" in result.output + + +# --------------------------------------------------------------------------- +# IntegrationCatalog — catalog source management (get_catalog_configs / add / remove) +# --------------------------------------------------------------------------- + + +class TestCatalogSourceManagement: + """Unit tests for add_catalog / remove_catalog / get_catalog_configs.""" + + def _isolate(self, tmp_path, monkeypatch): + """Point HOME at tmp_path and clear the env override so we read built-ins.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False) + (tmp_path / ".specify").mkdir() + + def test_get_catalog_configs_returns_builtin_stack(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + configs = cat.get_catalog_configs() + assert [c["name"] for c in configs] == ["default", "community"] + assert all(isinstance(c["url"], str) and c["url"] for c in configs) + assert configs[0]["install_allowed"] is True + assert configs[1]["install_allowed"] is False + + def test_add_catalog_creates_config_file(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://new.example.com/catalog.json", name="mine") + + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + assert cfg_path.exists() + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"] == [ + { + "name": "mine", + "url": "https://new.example.com/catalog.json", + "priority": 1, + "install_allowed": True, + "description": "", + } + ] + # Round-trip: active catalogs should now come from the config file. + active = cat.get_active_catalogs() + assert [e.name for e in active] == ["mine"] + + def test_add_catalog_recovers_from_empty_config_file(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text("", encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://example.com/catalog.json") + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"] == [ + { + "name": "catalog-1", + "url": "https://example.com/catalog.json", + "priority": 1, + "install_allowed": True, + "description": "", + } + ] + + @pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"]) + def test_add_catalog_rejects_falsy_non_mapping_config_roots( + self, tmp_path, monkeypatch, config_content + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text(config_content, encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, + match="corrupted.*expected a mapping", + ) as exc_info: + cat.add_catalog("https://example.com/catalog.json") + assert str(cfg_path) in str(exc_info.value) + + def test_add_catalog_auto_derives_name_and_priority(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://a.example.com/catalog.json") + cat.add_catalog("https://b.example.com/catalog.json") + + data = yaml.safe_load( + (tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8") + ) + entries = data["catalogs"] + assert [e["name"] for e in entries] == ["catalog-1", "catalog-2"] + assert [e["priority"] for e in entries] == [1, 2] + + def test_add_catalog_normalizes_name(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://a.example.com/catalog.json", name=" mine ") + cat.add_catalog("https://b.example.com/catalog.json", name=" ") + + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + entries = data["catalogs"] + assert [e["name"] for e in entries] == ["mine", "catalog-2"] + + def test_add_catalog_rejects_duplicate_url(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://dup.example.com/catalog.json") + with pytest.raises(IntegrationValidationError, match="already configured"): + cat.add_catalog("https://dup.example.com/catalog.json") + + def test_add_catalog_rejects_invalid_url(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationCatalogError, match="HTTPS"): + cat.add_catalog("http://insecure.example.com/catalog.json") + assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists() + + def test_add_catalog_rejects_empty_url(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationValidationError, match="must be non-empty"): + cat.add_catalog(" ") + assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists() + + def test_remove_catalog_without_config_errors(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationValidationError, match="No catalog config"): + cat.remove_catalog(0) + + def test_remove_catalog_happy_path(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://a.example.com/catalog.json", name="a") + cat.add_catalog("https://b.example.com/catalog.json", name="b") + + removed = cat.remove_catalog(0) + assert removed == "a" + + data = yaml.safe_load( + (tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8") + ) + assert [e["name"] for e in data["catalogs"]] == ["b"] + + def test_remove_catalog_index_out_of_range(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://a.example.com/catalog.json", name="a") + with pytest.raises(IntegrationValidationError, match="out of range"): + cat.remove_catalog(5) + with pytest.raises(IntegrationValidationError, match="out of range"): + cat.remove_catalog(-1) + + def test_corrupt_config_rejected_on_add(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text("- just\n- a\n- list\n", encoding="utf-8") + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationValidationError, match="corrupted") as exc_info: + cat.add_catalog("https://new.example.com/catalog.json") + assert str(cfg_path) in str(exc_info.value) + + def test_add_catalog_rejects_non_list_catalogs_with_config_path( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8" + ) + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="invalid 'catalogs' value" + ) as exc_info: + cat.add_catalog("https://new.example.com/catalog.json") + assert str(cfg_path) in str(exc_info.value) + + def test_add_catalog_rejects_non_mapping_entry_with_config_path( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump({"catalogs": ["not-a-mapping"]}), encoding="utf-8" + ) + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="Invalid catalog entry at index 0" + ) as exc_info: + cat.add_catalog("https://new.example.com/catalog.json") + message = str(exc_info.value) + assert str(cfg_path) in message + assert "expected a mapping" in message + + def test_add_catalog_skips_blank_url_entries(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": " ", "name": "blank", "priority": 99}, + { + "url": "https://a.example.com/catalog.json", + "name": "a", + "priority": 5, + }, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://b.example.com/catalog.json", name="b") + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"][-1]["name"] == "b" + assert data["catalogs"][-1]["priority"] == 6 + + def test_add_catalog_default_name_ignores_blank_url_entries( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump({"catalogs": [{"url": " ", "name": "blank"}]}), + encoding="utf-8", + ) + + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://example.com/catalog.json") + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"][-1]["name"] == "catalog-1" + + def test_add_catalog_rejects_non_integer_priority(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://a.example.com/catalog.json", + "name": "a", + "priority": "first", + } + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, + match="'priority' must be an integer, got 'first'", + ): + cat.add_catalog("https://b.example.com/catalog.json") + + def test_add_catalog_accepts_numeric_string_priority(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://a.example.com/catalog.json", + "name": "a", + "priority": "10", + } + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://b.example.com/catalog.json", name="b") + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"][-1]["name"] == "b" + assert data["catalogs"][-1]["priority"] == 11 + + @pytest.mark.parametrize( + ("bad_url", "reason"), + [ + ("http://insecure.example.com/catalog.json", "HTTPS"), + (123, "HTTPS"), + ], + ) + def test_add_catalog_rejects_existing_entry_with_bad_url( + self, tmp_path, monkeypatch, bad_url, reason + ): + """A sibling entry with an http:// URL should block a new add.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": bad_url, + "name": "bad", + } + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + with pytest.raises(IntegrationValidationError) as exc_info: + cat.add_catalog("https://good.example.com/catalog.json") + message = str(exc_info.value) + assert str(cfg_path) in message + assert "index 0" in message + assert reason in message + + def test_add_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch): + """Invalid YAML on disk surfaces as IntegrationValidationError, not a raw YAMLError.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n" + cfg_path.write_text(invalid_yaml, encoding="utf-8") + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="Failed to read catalog config" + ): + cat.add_catalog("https://b.example.com/catalog.json") + + def test_remove_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch): + """Invalid YAML on disk surfaces as IntegrationValidationError from remove_catalog too.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n" + cfg_path.write_text(invalid_yaml, encoding="utf-8") + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="Failed to read catalog config" + ): + cat.remove_catalog(0) + + def test_add_catalog_defaults_missing_priority_to_index_plus_one( + self, tmp_path, monkeypatch + ): + """Existing entries without `priority` should be treated as idx + 1. + + Matches the rule in `_load_catalog_config()`: a valid catalog entry + without an explicit `priority` sorts at `idx + 1`, so the new entry + should get `max(...) + 1` from those derived values. + """ + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + # No explicit priority → should be treated as 1 + {"url": "https://a.example.com/cat.json", "name": "a"}, + # No explicit priority → should be treated as 2 + {"url": "https://b.example.com/cat.json", "name": "b"}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://c.example.com/cat.json", name="c") + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + new_entry = data["catalogs"][-1] + assert new_entry["name"] == "c" + # max(implicit [1, 2]) + 1 == 3 + assert new_entry["priority"] == 3 + + def test_add_catalog_strips_whitespace_in_url(self, tmp_path, monkeypatch): + """Whitespace around the incoming URL should be normalized before write.""" + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog(" https://a.example.com/catalog.json\n", name="a") + + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert data["catalogs"][0]["url"] == "https://a.example.com/catalog.json" + + def test_add_catalog_rejects_whitespace_only_duplicate(self, tmp_path, monkeypatch): + """A second add with only whitespace differences must be rejected as a duplicate.""" + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://a.example.com/catalog.json", name="a") + with pytest.raises(IntegrationValidationError, match="already configured"): + cat.add_catalog(" https://a.example.com/catalog.json ") + + def test_remove_catalog_wraps_unlink_oserror(self, tmp_path, monkeypatch): + """An OSError from `Path.unlink` surfaces as IntegrationValidationError.""" + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://only.example.com/catalog.json", name="only") + + from pathlib import Path as _Path + + def boom(self, *args, **kwargs): + raise OSError("simulated unlink failure") + + monkeypatch.setattr(_Path, "unlink", boom) + + with pytest.raises( + IntegrationValidationError, match="Failed to delete catalog config" + ): + cat.remove_catalog(0) + + def test_remove_catalog_ignores_missing_final_config_during_unlink( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://only.example.com/catalog.json", name="only") + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + + from pathlib import Path as _Path + + original_unlink = _Path.unlink + + def delete_first_then_unlink(self, *args, **kwargs): + if self == cfg_path and self.exists(): + original_unlink(self) + return original_unlink(self, *args, **kwargs) + + monkeypatch.setattr(_Path, "unlink", delete_first_then_unlink) + + assert cat.remove_catalog(0) == "only" + assert not cfg_path.exists() + + def test_remove_catalog_empty_list_gives_clear_error(self, tmp_path, monkeypatch): + """Hand-edited empty `catalogs:` produces a clear error, not '0--1'.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text(yaml.dump({"catalogs": []}), encoding="utf-8") + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="contains no catalog entries" + ): + cat.remove_catalog(0) + + def test_remove_catalog_empty_config_file_gives_clear_error( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text("", encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="contains no catalog entries" + ): + cat.remove_catalog(0) + + def test_remove_catalog_rejects_non_list_catalogs_with_config_path( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8" + ) + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="invalid 'catalogs' value" + ) as exc_info: + cat.remove_catalog(0) + assert str(cfg_path) in str(exc_info.value) + + @pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"]) + def test_remove_catalog_rejects_falsy_non_mapping_config_roots( + self, tmp_path, monkeypatch, config_content + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text(config_content, encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, + match="corrupted.*expected a mapping", + ) as exc_info: + cat.remove_catalog(0) + assert str(cfg_path) in str(exc_info.value) + + def test_remove_last_catalog_deletes_file_and_restores_defaults( + self, tmp_path, monkeypatch + ): + """Removing the final catalog must not leave behind `catalogs: []`. + + `_load_catalog_config` treats an empty `catalogs` list as an error, + so writing that file would break every subsequent `integration` + command. Removing the last entry should delete the config file so the + project falls back to built-in defaults. + """ + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + + cat.add_catalog("https://only.example.com/catalog.json", name="only") + assert cfg_path.exists() + assert [e.name for e in cat.get_active_catalogs()] == ["only"] + + removed = cat.remove_catalog(0) + assert removed == "only" + + assert not cfg_path.exists(), ( + "remove_catalog should delete the config file when emptying it" + ) + # Follow-up loads fall back to built-in defaults, not an error. + active = cat.get_active_catalogs() + assert [e.name for e in active] == ["default", "community"] + + def test_load_catalog_config_raises_validation_error_for_invalid_yaml( + self, tmp_path, monkeypatch + ): + """Local-config problems must surface as IntegrationValidationError so + CLI handlers can route them to local-config (not network) guidance.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + invalid_yaml = "catalogs:\n - [bad\n" + cfg_path.write_text(invalid_yaml, encoding="utf-8") + + cat = IntegrationCatalog(tmp_path) + # Subclass match: IntegrationValidationError (specifically), not the + # bare IntegrationCatalogError parent that callers used previously. + with pytest.raises(IntegrationValidationError, match="Failed to read catalog config"): + cat.get_active_catalogs() + + def test_load_catalog_config_rejects_boolean_priority(self, tmp_path, monkeypatch): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": "https://a.example.com/catalog.json", + "name": "a", + "priority": True, + } + ] + } + ), + encoding="utf-8", + ) + + cat = IntegrationCatalog(tmp_path) + with pytest.raises( + IntegrationValidationError, match="Invalid priority|expected integer" + ) as exc_info: + cat.get_active_catalogs() + assert str(cfg_path) in str(exc_info.value) + + @pytest.mark.parametrize("raw_name", [None, " "]) + def test_load_catalog_config_defaults_blank_names( + self, tmp_path, monkeypatch, raw_name + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "url": " ", + "name": "skipped", + }, + { + "url": "https://example.com/catalog.json", + "name": raw_name, + } + ] + } + ), + encoding="utf-8", + ) + + cat = IntegrationCatalog(tmp_path) + + assert [entry.name for entry in cat.get_active_catalogs()] == ["catalog-1"] + + @pytest.mark.parametrize( + ("raw_name", "expected"), + [ + (None, "https://one.example.com/c.json"), + (" ", "https://one.example.com/c.json"), + (123, "123"), + ], + ) + def test_remove_catalog_normalizes_removed_display_name( + self, tmp_path, monkeypatch, raw_name, expected + ): + self._isolate(tmp_path, monkeypatch) + cat = IntegrationCatalog(tmp_path) + cat.add_catalog("https://one.example.com/c.json", name="one") + + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + data["catalogs"][0]["name"] = raw_name + cfg_path.write_text(yaml.dump(data), encoding="utf-8") + + assert cat.remove_catalog(0) == expected + + def test_remove_catalog_uses_display_order_with_explicit_priorities( + self, tmp_path, monkeypatch + ): + """`remove_catalog(index)` must remove the entry shown at that index by + `catalog list`, not the entry at that raw YAML position.""" + self._isolate(tmp_path, monkeypatch) + # YAML order: alpha (priority=20), beta (priority=10), gamma (priority=15). + # Display (sorted by priority asc): beta (10), gamma (15), alpha (20). + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": "https://alpha.example.com/c.json", "name": "alpha", "priority": 20}, + {"url": "https://beta.example.com/c.json", "name": "beta", "priority": 10}, + {"url": "https://gamma.example.com/c.json", "name": "gamma", "priority": 15}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + # Display index 0 = beta (lowest priority), not alpha (raw YAML idx 0). + removed = cat.remove_catalog(0) + assert removed == "beta" + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + remaining_names = [c["name"] for c in data["catalogs"]] + # YAML order is preserved for the survivors; only beta is gone. + assert remaining_names == ["alpha", "gamma"] + + def test_remove_catalog_display_order_with_missing_priorities( + self, tmp_path, monkeypatch + ): + """Entries without `priority` default to `idx + 1` (matching + `_load_catalog_config`), so display order tracks YAML order and the + first display entry is the first YAML entry.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": "https://one.example.com/c.json", "name": "one"}, + {"url": "https://two.example.com/c.json", "name": "two"}, + {"url": "https://three.example.com/c.json", "name": "three"}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + # Implicit priorities: one=1, two=2, three=3 → display order matches YAML. + removed = cat.remove_catalog(0) + assert removed == "one" + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert [c["name"] for c in data["catalogs"]] == ["two", "three"] + + def test_remove_catalog_bool_priority_falls_back_to_yaml_index( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": "https://one.example.com/c.json", "name": "one"}, + { + "url": "https://bool.example.com/c.json", + "name": "bool", + "priority": False, + }, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + removed = cat.remove_catalog(0) + + assert removed == "one" + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert [c["name"] for c in data["catalogs"]] == ["bool"] + + def test_remove_catalog_display_order_skips_blank_url_entries( + self, tmp_path, monkeypatch + ): + """Blank-url entries are not shown by catalog list, so remove skips them too.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": " ", "name": "blank", "priority": 0}, + {"url": "https://one.example.com/c.json", "name": "one"}, + {"url": "https://two.example.com/c.json", "name": "two"}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + removed = cat.remove_catalog(0) + assert removed == "one" + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert [c["name"] for c in data["catalogs"]] == ["blank", "two"] + + def test_remove_catalog_deletes_file_when_only_skipped_entries_remain( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": " ", "name": "blank", "priority": 0}, + {"url": "https://one.example.com/c.json", "name": "one"}, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + removed = cat.remove_catalog(0) + assert removed == "one" + assert not cfg_path.exists() + + active = cat.get_active_catalogs() + assert [e.name for e in active] == ["default", "community"] + + def test_remove_catalog_allows_numeric_url_entry_cleanup( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump({"catalogs": [{"name": "numeric-url", "url": 123}]}), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + removed = cat.remove_catalog(0) + + assert removed == "numeric-url" + assert not cfg_path.exists() + + def test_remove_catalog_errors_when_no_entries_are_removable( + self, tmp_path, monkeypatch + ): + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": "", "name": "empty"}, + {"name": "missing"}, + "not-a-mapping", + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + with pytest.raises( + IntegrationValidationError, + match="no removable catalog entries", + ): + cat.remove_catalog(0) + + def test_remove_catalog_display_order_mixes_explicit_and_default( + self, tmp_path, monkeypatch + ): + """An explicit low priority should sort ahead of default-priority + siblings, even if it appears later in the YAML.""" + self._isolate(tmp_path, monkeypatch) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + # Defaults: a=1, b=2 (implicit). Explicit c=0 → display: c, a, b. + # The blank name should fall back to the removed URL, not raw YAML idx. + cfg_path.write_text( + yaml.dump( + { + "catalogs": [ + {"url": "https://a.example.com/c.json", "name": "a"}, + {"url": "https://b.example.com/c.json", "name": "b"}, + { + "url": "https://c.example.com/c.json", + "name": " ", + "priority": 0, + }, + ] + } + ), + encoding="utf-8", + ) + cat = IntegrationCatalog(tmp_path) + + removed = cat.remove_catalog(0) + assert removed == "https://c.example.com/c.json" + + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) + assert [c["name"] for c in data["catalogs"]] == ["a", "b"] From c079b2cc320ce70a9c1c60f4ab47cf30ac1c03a5 Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Wed, 29 Apr 2026 17:39:45 +0300 Subject: [PATCH 138/184] fix: dispatch opencode commands via run (#2410) --- .../integrations/opencode/__init__.py | 24 ++++++++++ .../integrations/test_integration_opencode.py | 48 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index be4dcc3094..17db2bd11b 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -19,3 +19,27 @@ class OpencodeIntegration(MarkdownIntegration): "extension": ".md", } context_file = "AGENTS.md" + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + args = [self.key, "run"] + + message = prompt + if prompt.startswith("/"): + command, _, remainder = prompt[1:].partition(" ") + if command: + args.extend(["--command", command]) + message = remainder + + if model: + args.extend(["-m", model]) + if output_json: + args.extend(["--format", "json"]) + if message: + args.append(message) + return args diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index 4f3aee5d9b..427fd15167 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -1,5 +1,7 @@ """Tests for OpencodeIntegration.""" +from specify_cli.integrations import get_integration + from .test_integration_base_markdown import MarkdownIntegrationTests @@ -9,3 +11,49 @@ class TestOpencodeIntegration(MarkdownIntegrationTests): COMMANDS_SUBDIR = "command" REGISTRAR_DIR = ".opencode/command" CONTEXT_FILE = "AGENTS.md" + + def test_build_exec_args_uses_run_command_dispatch(self): + integration = get_integration(self.KEY) + + args = integration.build_exec_args( + "/speckit.specify build a login page", + output_json=False, + ) + + assert args == [ + "opencode", + "run", + "--command", + "speckit.specify", + "build a login page", + ] + assert "-p" not in args + assert "--output-format" not in args + + def test_build_exec_args_maps_model_and_json_flags(self): + integration = get_integration(self.KEY) + + args = integration.build_exec_args( + "/speckit.plan add OAuth", + model="anthropic/claude-sonnet-4", + output_json=True, + ) + + assert args == [ + "opencode", + "run", + "--command", + "speckit.plan", + "-m", + "anthropic/claude-sonnet-4", + "--format", + "json", + "add OAuth", + ] + + def test_build_exec_args_keeps_plain_prompt_dispatch(self): + integration = get_integration(self.KEY) + + args = integration.build_exec_args("explain this repository", output_json=False) + + assert args == ["opencode", "run", "explain this repository"] From ab9c70262d7426af03dd32e1b05f731148938d78 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 29 Apr 2026 20:12:04 +0500 Subject: [PATCH 139/184] fix: include --from git+... in upgrade hint to avoid PyPI squat package (#2411) The compatibility-error messages in extensions.py and presets.py, plus the extension troubleshooting guide, told users to upgrade with: uv tool install specify-cli --force Without `--from git+https://github.com/github/spec-kit.git`, uv resolves `specify-cli` from PyPI, where an unrelated package with the same name (no author, no project URLs) ships a stub CLI that lacks `extension`, `preset`, and most spec-kit commands. Users following the upgrade hint land on the squat package and report "extension command removed" (see #1982). Reuse the existing `REINSTALL_COMMAND` constant in extensions.py and import it from presets.py so all three call sites point at the GitHub source. The doc fix also adds a one-line note explaining the PyPI collision so the same advice doesn't get re-stripped later. Refs #1982 --- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 2 +- src/specify_cli/extensions.py | 2 +- src/specify_cli/presets.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index dfc1125228..42ce2d71df 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -669,7 +669,7 @@ hooks: **Error**: `Extension requires spec-kit >=0.2.0` -- **Fix**: Update spec-kit with `uv tool install specify-cli --force` +- **Fix**: Update spec-kit with `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git`. The bare `specify-cli` package on PyPI is a different, unrelated project — installing it without `--from git+...` will give you a stub CLI that does not include `extension`, `preset`, or other spec-kit commands. **Error**: `Command file not found` diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index a419ebf1d2..761b7f31e7 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1108,7 +1108,7 @@ def check_compatibility( raise CompatibilityError( f"Extension requires spec-kit {required}, " f"but {speckit_version} is installed.\n" - f"Upgrade spec-kit with: uv tool install specify-cli --force" + f"Upgrade spec-kit with: {REINSTALL_COMMAND}" ) except InvalidSpecifier: raise CompatibilityError(f"Invalid version specifier: {required}") diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 27054a77fc..690d1c51ff 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -27,7 +27,7 @@ from packaging import version as pkg_version from packaging.specifiers import SpecifierSet, InvalidSpecifier -from .extensions import ExtensionRegistry, normalize_priority +from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority def _substitute_core_template( @@ -576,7 +576,7 @@ def check_compatibility( raise PresetCompatibilityError( f"Preset requires spec-kit {required}, " f"but {speckit_version} is installed.\n" - f"Upgrade spec-kit with: uv tool install specify-cli --force" + f"Upgrade spec-kit with: {REINSTALL_COMMAND}" ) except InvalidSpecifier: raise PresetCompatibilityError( From 237e918f110fe4bfb314889023d1852556c16df6 Mon Sep 17 00:00:00 2001 From: vishal-gandhi Date: Wed, 29 Apr 2026 16:22:06 -0500 Subject: [PATCH 140/184] feat(integrations): add Devin for Terminal skills-based integration (#2364) * feat(integrations): add Devin for Terminal skills-based integration - Register DevinIntegration as a SkillsIntegration with .devin/skills/ layout - Add catalog entry, docs row, and supported-agents listing - Display /speckit- hyphen syntax in init "Next Steps" panel (matches Claude/Cursor/Copilot skills mode, since Devin invokes skills by directory name) Closes #2346 * fix(devin): implement -p non-interactive dispatch; clarify skills comment Addresses Copilot review on PR #2364: - Override build_exec_args() in DevinIntegration to emit 'devin -p [--model X]' for non-interactive text dispatch (verified Devin CLI supports -p / --print). Returns None when output_json=True since Devin has no structured-output flag, so CommandStep workflows that require JSON cleanly raise NotImplementedError instead of crashing on an unknown CLI flag. requires_cli=True is retained for tool detection. - Extend the skills-integrations enumeration comment in specify_cli/__init__.py to include copilot and devin so the comment matches the code below it. * fix(devin): always return exec args; document plain-text stdout Addresses third Copilot review comment on PR #2364. Returning None from build_exec_args() when output_json=True incorrectly used the codebase's IDE-only sentinel: workflow CommandStep checks 'impl.build_exec_args("test") is None' to detect non-dispatchable integrations (test_workflows.py exercises this with WindsurfIntegration). The previous implementation made Devin appear non-dispatchable to all command steps even though it runs fine via 'devin -p'. Always return the args list. When output_json is requested, Devin is still dispatched and returns plain-text stdout instead of structured JSON; the docstring documents this explicitly. * docs(devin): include claude in skills-integrations enumeration comment Addresses Copilot review on PR #2364: the comment listing skills integrations omitted Claude, which is also a SkillsIntegration subclass. Updated to keep the comment accurate for future readers. * test(devin): add build_exec_args regression tests; bump catalog updated_at Addresses Copilot review on PR #2364, per @mnriem's request to 'address the Copilot feedback, especially the testing ask': - tests/integrations/test_integration_devin.py: add TestDevinBuildExecArgs with three regression assertions: * build_exec_args returns args (not the None IDE-only sentinel) * --output-format is never emitted, regardless of output_json * --model flag is passed through correctly - integrations/catalog.json: bump top-level updated_at to reflect the Devin entry addition so downstream catalog consumers can detect the change reliably. --- .github/ISSUE_TEMPLATE/agent_request.yml | 2 +- docs/reference/integrations.md | 1 + integrations/catalog.json | 11 ++- src/specify_cli/__init__.py | 10 ++- src/specify_cli/integrations/__init__.py | 2 + .../integrations/devin/__init__.py | 65 ++++++++++++++++ tests/integrations/test_integration_devin.py | 75 +++++++++++++++++++ 7 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 src/specify_cli/integrations/devin/__init__.py create mode 100644 tests/integrations/test_integration_devin.py diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index 37b0fea5bf..1a44adec2d 100644 --- a/.github/ISSUE_TEMPLATE/agent_request.yml +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -8,7 +8,7 @@ body: value: | Thanks for requesting a new agent! Before submitting, please check if the agent is already supported. - **Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI + **Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI, Devin for Terminal - type: input id: agent-name diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index dcb9a2b354..e1b8e60a8b 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -13,6 +13,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [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-` | | [Cursor](https://cursor.sh/) | `cursor-agent` | | +| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | | [Forge](https://forgecode.dev/) | `forge` | | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | | | [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | | diff --git a/integrations/catalog.json b/integrations/catalog.json index 3df96b8789..aad8f14f76 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-08T00:00:00Z", + "updated_at": "2026-04-28T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "claude": { @@ -66,6 +66,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli", "skills"] }, + "devin": { + "id": "devin", + "name": "Devin for Terminal", + "version": "1.0.0", + "description": "Devin for Terminal CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] + }, "qwen": { "id": "qwen", "name": "Qwen Code", diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f5e117beef..8039f79983 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1528,7 +1528,7 @@ def init( step_num = 2 # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax. + # Skills integrations (codex, claude, kimi, agy, trae, cursor-agent, copilot, devin) should show skill invocation syntax. from .integrations.base import SkillsIntegration as _SkillsInt _is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) @@ -1539,7 +1539,8 @@ def init( trae_skill_mode = selected_ai == "trae" 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 - 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 + devin_skill_mode = selected_ai == "devin" + 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: # Integration path installed skills; show the helpful notice @@ -1551,6 +1552,9 @@ def init( if cursor_agent_skill_mode and not ai_skills: steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]") step_num += 1 + if devin_skill_mode: + steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]") + step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: @@ -1560,7 +1564,7 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" - if cursor_agent_skill_mode or copilot_skill_mode: + if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode: return f"/speckit-{name}" return f"/speckit.{name}" diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index a5fb3833dc..79ada4ddfc 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -56,6 +56,7 @@ def _register_builtins() -> None: from .codex import CodexIntegration from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration + from .devin import DevinIntegration from .forge import ForgeIntegration from .gemini import GeminiIntegration from .generic import GenericIntegration @@ -86,6 +87,7 @@ def _register_builtins() -> None: _register(CodexIntegration()) _register(CopilotIntegration()) _register(CursorAgentIntegration()) + _register(DevinIntegration()) _register(ForgeIntegration()) _register(GeminiIntegration()) _register(GenericIntegration()) diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py new file mode 100644 index 0000000000..f5656e4aef --- /dev/null +++ b/src/specify_cli/integrations/devin/__init__.py @@ -0,0 +1,65 @@ +"""Devin for Terminal integration — skills-based agent. + +Devin uses the ``.devin/skills/speckit-/SKILL.md`` layout and +reads project context from ``AGENTS.md`` at the repo root. The CLI +binary is ``devin`` and skills are invoked via ``/`` inside an +interactive ``devin`` session. + +See: https://cli.devin.ai/docs/extensibility/skills/overview +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class DevinIntegration(SkillsIntegration): + """Integration for Cognition AI's Devin for Terminal.""" + + key = "devin" + config = { + "name": "Devin for Terminal", + "folder": ".devin/", + "commands_subdir": "skills", + "install_url": "https://cli.devin.ai/docs", + "requires_cli": True, + } + registrar_config = { + "dir": ".devin/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = "AGENTS.md" + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build non-interactive CLI args for Devin for Terminal. + + Devin supports ``devin -p `` for single-turn execution + and ``--model`` for model selection, but its CLI has no flag + for structured JSON output. When ``output_json`` is requested, + Devin is still dispatched normally and returns plain-text + stdout instead of structured JSON. ``requires_cli=True`` is + kept on the integration for tool detection. + """ + args = [self.key, "-p", prompt] + if model: + args.extend(["--model", model]) + return args + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Devin)", + ), + ] \ No newline at end of file diff --git a/tests/integrations/test_integration_devin.py b/tests/integrations/test_integration_devin.py new file mode 100644 index 0000000000..d218513d63 --- /dev/null +++ b/tests/integrations/test_integration_devin.py @@ -0,0 +1,75 @@ +"""Tests for DevinIntegration.""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestDevinIntegration(SkillsIntegrationTests): + KEY = "devin" + FOLDER = ".devin/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".devin/skills" + CONTEXT_FILE = "AGENTS.md" + + +class TestDevinBuildExecArgs: + """Regression tests for DevinIntegration.build_exec_args. + + Devin's CLI has no --output-format flag, so build_exec_args must + omit it regardless of the output_json argument. The integration + must also remain dispatchable (must not return None, which is the + codebase's IDE-only sentinel checked by CommandStep). + """ + + def test_returns_args_not_none_for_dispatch(self): + """Devin is CLI-dispatchable; build_exec_args must not return None.""" + from specify_cli.integrations.devin import DevinIntegration + + impl = DevinIntegration() + args = impl.build_exec_args("test prompt") + assert args is not None, ( + "DevinIntegration.build_exec_args must not return None. " + "None is the codebase sentinel for IDE-only integrations " + "(see WindsurfIntegration); Devin is dispatchable via 'devin -p'." + ) + assert args[:3] == ["devin", "-p", "test prompt"] + + def test_output_json_does_not_emit_output_format_flag(self): + """Devin has no --output-format flag; output_json=True must not add it.""" + from specify_cli.integrations.devin import DevinIntegration + + impl = DevinIntegration() + args_json = impl.build_exec_args("hello", output_json=True) + args_text = impl.build_exec_args("hello", output_json=False) + + assert "--output-format" not in args_json + assert "json" not in args_json[3:] + # The two should be identical: output_json is documented as having + # no effect on the command line for Devin (plain-text stdout). + assert args_json == args_text + + def test_model_flag_passed_through(self): + """--model is supported and should appear when provided.""" + from specify_cli.integrations.devin import DevinIntegration + + impl = DevinIntegration() + args = impl.build_exec_args("hi", model="claude-sonnet-4") + assert args == ["devin", "-p", "hi", "--model", "claude-sonnet-4"] + + +class TestDevinAutoPromote: + """--ai devin auto-promotes to integration path.""" + + def test_ai_devin_without_ai_skills_auto_promotes(self, tmp_path): + """--ai devin should work the same as --integration devin.""" + from typer.testing import CliRunner + from specify_cli import app + + runner = CliRunner() + target = tmp_path / "test-proj" + result = runner.invoke( + app, + ["init", str(target), "--ai", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"], + ) + + assert result.exit_code == 0, f"init --ai devin failed: {result.output}" + assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists() \ No newline at end of file From 2cb848f0d3a1df273c61bb177522311f4a81ce9d Mon Sep 17 00:00:00 2001 From: Sakit Date: Wed, 29 Apr 2026 23:41:19 +0200 Subject: [PATCH 141/184] Add Work IQ extension to community catalog (#2415) * Add Work IQ extension to community catalog Adds the Work IQ extension by sakitA to the community catalog. Work IQ integrates Microsoft 365 organizational knowledge (emails, meetings, documents, Teams) into spec-driven development workflows. - 4 commands: ask, context, stakeholders, enrich - 2 hooks: before_specify, after_specify - Requires: speckit >=0.1.0, Node.js >=18.0.0, workiq CLI Repository: https://github.com/sakitA/spec-kit-workiq * Address PR review comments - Fix download_url to use .zip (Spec Kit installer requires ZIP format) - Bump top-level catalog updated_at to 2026-04-29 Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Sakit Atakishiyev Co-authored-by: Claude Sonnet 4.6 --- README.md | 1 + extensions/catalog.community.json | 44 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/README.md b/README.md index 6450e35601..95cfbaead3 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,7 @@ The following community-contributed extensions are available in [`catalog.commun | Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) | | What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) | | Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) | +| Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) | | Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) | | Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 6d0537a0c0..fc71dbbd4c 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2613,6 +2613,50 @@ "created_at": "2026-04-22T00:00:00Z", "updated_at": "2026-04-22T00:00:00Z" }, + "workiq": { + "name": "Work IQ", + "id": "workiq", + "description": "Integrate Microsoft 365 organizational knowledge into spec-driven development workflows", + "author": "sakitA", + "version": "1.0.0", + "download_url": "https://github.com/sakitA/spec-kit-workiq/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/sakitA/spec-kit-workiq", + "homepage": "https://github.com/sakitA/spec-kit-workiq", + "documentation": "https://github.com/sakitA/spec-kit-workiq/blob/main/README.md", + "changelog": "https://github.com/sakitA/spec-kit-workiq/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "workiq", + "version": ">=1.0.0", + "required": true + }, + { + "name": "node", + "version": ">=18.0.0", + "required": true + } + ] + }, + "provides": { + "commands": 4, + "hooks": 2 + }, + "tags": [ + "microsoft-365", + "work-iq", + "context", + "integration", + "productivity" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-29T00:00:00Z", + "updated_at": "2026-04-29T00:00:00Z" + }, "worktree": { "name": "Worktree Isolation", "id": "worktree", From 7cedd85f2ac9a58b20c0fde5e7a3e1225f6e76fc Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:50:09 -0500 Subject: [PATCH 142/184] chore: release 0.8.3, begin 0.8.4.dev0 development (#2418) * chore: bump version to 0.8.3 * chore: begin 0.8.4.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8202610ff3..52d2c87cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.8.3] - 2026-04-29 + +### Changed + +- Add Work IQ extension to community catalog (#2415) +- feat(integrations): add Devin for Terminal skills-based integration (#2364) +- fix: include --from git+... in upgrade hint to avoid PyPI squat package (#2411) +- fix: dispatch opencode commands via run (#2410) +- feat: add catalog discovery CLI commands (#2360) +- update security review extension catalog to v1.3.0 (#2374) +- chore(catalog): bump v-model extension to v0.6.0 (#2399) +- feat: add threatmodel extension to community catalog (#2369) +- Add isaqb-architecture-governance to community catalog (#2385) +- chore: release 0.8.2, begin 0.8.3.dev0 development (#2397) + ## [0.8.2] - 2026-04-28 ### Changed diff --git a/pyproject.toml b/pyproject.toml index ffd0a0aaa4..2c5980d38f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.8.3.dev0" +version = "0.8.4.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From da1bf028abd11d3a95f1eb1af8b5e76ef8e55634 Mon Sep 17 00:00:00 2001 From: Jeff Williams Date: Wed, 29 Apr 2026 18:05:08 -0400 Subject: [PATCH 143/184] feat: add Squad Bridge extension to community catalog (#2417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add squad bridge extension to community catalog Adds spec-kit-squad by jwill824 — a Spec Kit extension that bootstraps and synchronizes a Squad agent team from your Speckit spec and tasks. - 4 commands: init, generate, route, status - 2 hooks: after_specify (generate), after_tasks (route) - v1.0.0 release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: add requires.tools for squad-cli in catalog entry * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 39 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/README.md b/README.md index 95cfbaead3..b52e3822b4 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) | | SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | +| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | | Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) | | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index fc71dbbd4c..e336b95107 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2160,6 +2160,45 @@ "created_at": "2026-04-10T16:00:00Z", "updated_at": "2026-04-10T16:00:00Z" }, + "squad": { + "name": "Squad Bridge", + "id": "squad", + "description": "Bootstrap and synchronize a Squad agent team from your Spec Kit spec and tasks.", + "author": "jwill824", + "version": "1.1.0", + "download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.1.0.zip", + "repository": "https://github.com/jwill824/spec-kit-squad", + "homepage": "https://github.com/jwill824/spec-kit-squad", + "documentation": "https://github.com/jwill824/spec-kit-squad/blob/main/README.md", + "changelog": "https://github.com/jwill824/spec-kit-squad/blob/main/docs/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "@bradygaster/squad-cli", + "version": ">=0.1.0", + "required": true + } + ] + }, + "provides": { + "commands": 4, + "hooks": 2 + }, + "tags": [ + "multi-agent", + "agents", + "orchestration", + "process", + "integration" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-29T00:00:00Z", + "updated_at": "2026-04-29T00:00:00Z" + }, "staff-review": { "name": "Staff Review Extension", "id": "staff-review", From 5edc9a5358ce1e56a417bd797cd98b1915d1238a Mon Sep 17 00:00:00 2001 From: Chengyou Liu <35356271+cyliu0@users.noreply.github.com> Date: Fri, 1 May 2026 20:41:38 +0800 Subject: [PATCH 144/184] fix: migrate extension commands on integration switch (#2404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: migrate extension commands on integration switch When switching integrations (e.g. kimi → opencode), extension commands were not re-registered for the new agent, leaving the new agent without extension support and orphaning files in the old agent's directory. Changes: - Add ExtensionManager.unregister_agent_artifacts() to clean up old agent extension files and registry entries during switch - Add ExtensionManager.register_enabled_extensions_for_agent() to re-register all enabled extensions for the new agent - Wire both into integration_switch() after uninstall/install phases - Handle skills mode (Copilot --skills) correctly - Add tests for kimi→opencode→claude migration, Copilot skills mode, and disabled extension handling Fixes extension commands not appearing after integration switch. * Update src/specify_cli/extensions.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- EOF | 0 src/specify_cli/__init__.py | 26 +++ src/specify_cli/extensions.py | 179 +++++++++++++++++- .../test_integration_subcommand.py | 146 ++++++++++++++ 4 files changed, 342 insertions(+), 9 deletions(-) create mode 100644 EOF diff --git a/EOF b/EOF new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 8039f79983..71af7ca09f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2376,6 +2376,19 @@ def integration_switch( ) 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: + console.print( + f"[yellow]Warning:[/yellow] Could not clean up extension artifacts " + f"(commands, skills, registry entries) for '{installed_key}': {ext_err}" + ) + # Clear metadata so a failed Phase 2 doesn't leave stale references _remove_integration_json(project_root) opts = load_init_options(project_root) @@ -2415,6 +2428,19 @@ def integration_switch( _write_integration_json(project_root, target_integration.key) _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) + # 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: + console.print( + f"[yellow]Warning:[/yellow] Could not register extension commands, skills, " + f"or related artifacts for '{target}': {ext_err}" + ) + except Exception as e: # Attempt rollback of any files written by setup try: diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 761b7f31e7..81687b4186 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -962,29 +962,40 @@ def _register_extension_skills( return written - def _unregister_extension_skills(self, skill_names: List[str], extension_id: str) -> None: + def _unregister_extension_skills( + self, + skill_names: List[str], + extension_id: str, + skills_dir: Optional[Path] = None, + ) -> None: """Remove SKILL.md directories for extension skills. Called during extension removal to clean up skill files that were created by ``_register_extension_skills()``. - If ``_get_skills_dir()`` returns ``None`` (e.g. the user removed - init-options.json or toggled ai_skills after installation), we - fall back to scanning all known agent skills directories so that - orphaned skill directories are still cleaned up. In that case - each candidate directory is verified against the SKILL.md - ``metadata.source`` field before removal to avoid accidentally - deleting user-created skills with the same name. + If *skills_dir* is not provided and ``_get_skills_dir()`` returns + ``None`` (e.g. the user removed init-options.json or toggled + ai_skills after installation), we fall back to scanning all known + agent skills directories so that orphaned skill directories are + still cleaned up. In that case each candidate directory is + verified against the SKILL.md ``metadata.source`` field before + removal to avoid accidentally deleting user-created skills with + the same name. Args: skill_names: List of skill names to remove. extension_id: Extension ID used to verify ownership during fallback candidate scanning. + skills_dir: Optional explicit skills directory to use instead + of resolving via ``_get_skills_dir()``. Useful when the + caller needs to target a specific agent's skills directory + regardless of the currently-active agent in init-options. """ if not skill_names: return - skills_dir = self._get_skills_dir() + if skills_dir is None: + skills_dir = self._get_skills_dir() if skills_dir: # Fast path: we know the exact skills directory @@ -1332,6 +1343,156 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool: return True + @staticmethod + def _valid_name_list(value: Any) -> List[str]: + """Return string entries from a registry list, ignoring corrupt values.""" + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str)] + + def unregister_agent_artifacts(self, agent_name: str) -> None: + """Remove extension files registered for a specific agent. + + Extension command files are tracked per agent in ``registered_commands``. + Extension skills are scoped to the provided *agent_name*; they are removed + from that agent's skills directory (resolved via its integration config) + and the registry field is cleared. + + Skips cleanup when *agent_name* is not a supported agent to avoid + losing registry entries while leaving orphaned files on disk. + """ + if not agent_name: + return + + registrar = CommandRegistrar() + if agent_name not in registrar.AGENT_CONFIGS: + return + + # Resolve the skills directory for the specific agent so cleanup is + # agent-scoped and does not depend on the currently-active agent in + # init-options. Use the same helper that extension install uses. + from . import _get_skills_dir as resolve_skills_dir + + agent_skills_dir = resolve_skills_dir(self.project_root, agent_name) + + for ext_id, metadata in self.registry.list().items(): + updates: Dict[str, Any] = {} + + registered_commands = metadata.get("registered_commands", {}) + if isinstance(registered_commands, dict) and agent_name in registered_commands: + command_names = self._valid_name_list(registered_commands.get(agent_name)) + if command_names: + registrar.unregister_commands({agent_name: command_names}, self.project_root) + + new_registered = copy.deepcopy(registered_commands) + new_registered.pop(agent_name, None) + updates["registered_commands"] = new_registered + + registered_skills = self._valid_name_list(metadata.get("registered_skills", [])) + if registered_skills: + # Only pass the resolved skills_dir when it actually exists. + # Otherwise let _unregister_extension_skills fall back to + # scanning all known agent skills directories, which is useful + # for cleaning up stale entries created by earlier installs. + skills_dir = agent_skills_dir if agent_skills_dir.is_dir() else None + self._unregister_extension_skills( + registered_skills, ext_id, skills_dir=skills_dir + ) + + # Only reconcile registry state when cleanup was scoped to a + # specific existing directory. When skills_dir is None, + # _unregister_extension_skills falls back to scanning multiple + # candidate directories, so agent_skills_dir cannot be used to + # infer what was removed. When skills_dir is set, + # _unregister_extension_skills may intentionally skip deletion + # when ownership cannot be verified (e.g., corrupted/missing + # SKILL.md or mismatching metadata.source). Only drop registry + # entries for skill directories that were actually removed so + # future cleanup attempts can still find skipped ones. + if skills_dir is not None: + remaining_skills = [ + skill_name + for skill_name in registered_skills + if (skills_dir / skill_name).is_dir() + ] + if remaining_skills != registered_skills: + updates["registered_skills"] = remaining_skills + + if updates: + self.registry.update(ext_id, updates) + + def register_enabled_extensions_for_agent(self, agent_name: str) -> None: + """Register installed, enabled extensions for ``agent_name``. + + This is intended to be called after switching integrations. Command + registration is scoped to the explicit ``agent_name`` argument, but some + behavior still depends on the current init-options state (for example, + skills-mode handling uses the active ``ai`` / ``ai_skills`` settings). + + Callers should therefore pass the agent that has just been made active + in init-options; in normal use, ``agent_name`` is expected to match the + current ``ai`` value. This mirrors extension install behavior while + avoiding stale default-mode command directories when that active agent + is running in skills mode (notably Copilot ``--skills``). + """ + if not agent_name: + return + + from . import load_init_options + + registrar = CommandRegistrar() + agent_config = registrar.AGENT_CONFIGS.get(agent_name) + init_options = load_init_options(self.project_root) + if not isinstance(init_options, dict): + init_options = {} + + active_agent = init_options.get("ai") + skills_mode_active = ( + active_agent == agent_name + and bool(init_options.get("ai_skills")) + and bool(agent_config) + and agent_config.get("extension") != "/SKILL.md" + ) + + for ext_id, metadata in self.registry.list().items(): + if not metadata.get("enabled", True): + continue + + manifest = self.get_extension(ext_id) + if manifest is None: + continue + + ext_dir = self.extensions_dir / ext_id + updates: Dict[str, Any] = {} + + if agent_config and not skills_mode_active: + registered = registrar.register_commands_for_agent( + agent_name, manifest, ext_dir, self.project_root + ) + registered_commands = metadata.get("registered_commands", {}) + if not isinstance(registered_commands, dict): + registered_commands = {} + new_registered = copy.deepcopy(registered_commands) + if registered: + new_registered[agent_name] = registered + else: + # Registration returned empty list (e.g., corrupted + # manifest pointing at missing command files). Clear + # stale entry so later cleanup doesn't try to remove + # files that were never written. + new_registered.pop(agent_name, None) + if new_registered != registered_commands: + updates["registered_commands"] = new_registered + + registered_skills = self._register_extension_skills(manifest, ext_dir) + if registered_skills: + existing_skills = self._valid_name_list(metadata.get("registered_skills", [])) + merged_skills = list(dict.fromkeys(existing_skills + registered_skills)) + updates["registered_skills"] = merged_skills + + if updates: + self.registry.update(ext_id, updates) + def list_installed(self) -> List[Dict[str, Any]]: """List all installed extensions with metadata. diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index f5322bdf5e..3952557cf2 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -31,6 +31,16 @@ def _init_project(tmp_path, integration="copilot"): return project +def _run_in_project(project, args): + """Run a CLI command from inside a generated project.""" + old_cwd = os.getcwd() + try: + os.chdir(project) + return runner.invoke(app, args, catch_exceptions=False) + finally: + os.chdir(old_cwd) + + # ── list ───────────────────────────────────────────────────────────── @@ -334,6 +344,142 @@ def test_switch_between_integrations(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "copilot" + def test_switch_migrates_extension_commands(self, tmp_path): + """Switching should migrate extension commands to the new agent directory.""" + project = _init_project(tmp_path, "kimi") + + # Install the bundled git extension + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + # Verify git extension skills exist for kimi + kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md" + assert kimi_git_feature.exists(), "Git extension skill should exist for kimi" + + result = _run_in_project(project, [ + "integration", "switch", "opencode", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + # Git extension commands should exist for opencode + opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md" + assert opencode_git_feature.exists(), "Git extension command should exist for opencode" + + # Old kimi extension skills should be removed + assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed" + + # Extension registry should be updated + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + registered_commands = registry["extensions"]["git"]["registered_commands"] + assert "opencode" in registered_commands + assert "kimi" not in registered_commands + + # Switch to claude + result = _run_in_project(project, [ + "integration", "switch", "claude", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + # Git extension skills should exist for claude + claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + assert claude_git_feature.exists(), "Git extension skill should exist for claude" + + # Old opencode extension commands should be removed + assert not opencode_git_feature.exists(), "Old opencode extension command should be removed" + + # Extension registry should be updated + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + registered_commands = registry["extensions"]["git"]["registered_commands"] + assert "claude" in registered_commands + assert "opencode" not in registered_commands + + def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path): + """Copilot --skills should receive extension skills, not .agent.md files.""" + project = _init_project(tmp_path, "opencode") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + + result = _run_in_project(project, [ + "integration", "switch", "copilot", + "--script", "sh", + "--integration-options", "--skills", + ]) + assert result.exit_code == 0, result.output + + copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md" + copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md" + 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" + + # Verify Copilot-specific frontmatter: mode field should map from + # skill name (speckit-git-feature) back to dot notation (speckit.git-feature) + skill_content = copilot_git_feature.read_text(encoding="utf-8") + assert "mode: speckit.git-feature" in skill_content, ( + "Copilot skill frontmatter should contain mode mapped from skill name" + ) + + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + git_meta = registry["extensions"]["git"] + assert "speckit-git-feature" in git_meta["registered_skills"] + assert "copilot" not in git_meta["registered_commands"] + + result = _run_in_project(project, [ + "integration", "switch", "opencode", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md" + assert opencode_git_feature.exists(), "Git extension command should exist for opencode" + assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed" + + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + git_meta = registry["extensions"]["git"] + assert git_meta["registered_skills"] == [] + assert "opencode" in git_meta["registered_commands"] + assert "copilot" not in git_meta["registered_commands"] + + def test_switch_does_not_register_disabled_extensions(self, tmp_path): + """Disabled extensions should stay disabled and should not migrate commands.""" + project = _init_project(tmp_path, "opencode") + + result = _run_in_project(project, ["extension", "add", "git"]) + assert result.exit_code == 0, f"extension add failed: {result.output}" + result = _run_in_project(project, ["extension", "disable", "git"]) + assert result.exit_code == 0, result.output + + opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md" + assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch" + + result = _run_in_project(project, [ + "integration", "switch", "claude", + "--script", "sh", + ]) + assert result.exit_code == 0, result.output + + claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent" + assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch" + + registry = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8") + ) + git_meta = registry["extensions"]["git"] + assert git_meta["enabled"] is False + assert "claude" not in git_meta["registered_commands"] + assert "opencode" not in git_meta["registered_commands"] + def test_switch_preserves_shared_infra(self, tmp_path): """Switching preserves shared scripts, templates, and memory.""" project = _init_project(tmp_path, "claude") From 9fac01fb4714733e7b30040d31fcefbae0a21669 Mon Sep 17 00:00:00 2001 From: Alex Vieira Date: Fri, 1 May 2026 13:46:48 +0100 Subject: [PATCH 145/184] feat(extensions): add Spec2Cloud extension for Azure deployment workflow (#2412) * feat(extensions): add Spec2Cloud extension for Azure deployment workflow Co-authored-by: Copilot * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensions/catalog.community.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * feat(extensions): update Spec2Cloud extension details and remove duplicate entry Co-authored-by: Copilot * fix(extensions): correct formatting of updated_at timestamp * fix(extensions): update Spec2Cloud extension version and timestamps --------- Co-authored-by: Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 36 +++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b52e3822b4..024ff3ee7e 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,7 @@ The following community-contributed extensions are available in [`catalog.commun | Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) | | Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) | | Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) | +| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | | SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) | | Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) | | Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index e336b95107..ef68ce9c19 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-29T00:00:00Z", + "updated_at": "2026-04-30T09:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -2095,6 +2095,38 @@ "created_at": "2026-04-20T00:00:00Z", "updated_at": "2026-04-21T00:00:00Z" }, + "spec2cloud": { + "name": "Spec2Cloud", + "id": "spec2cloud", + "description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.", + "author": "Azure Samples", + "version": "1.1.0", + "download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/extension.zip", + "repository": "https://github.com/Azure-Samples/Spec2Cloud", + "homepage": "https://aka.ms/spec2cloud", + "documentation": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/README.md", + "changelog": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 2, + "hooks": 0 + }, + "tags": [ + "spec2cloud", + "azure", + "cloud", + "deploy", + "workflow" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-04-30T00:00:00Z", + "updated_at": "2026-04-30T00:00:00Z" + }, "speckit-utils": { "name": "SDD Utilities", "id": "speckit-utils", @@ -2758,7 +2790,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-13T00:00:00Z", - "updated_at": "2026-04-13T00:00:00Z" + "updated_at": "2026-04-13T00:00:00Z" } } } From b13eea1e2708a3a9331a13f7b179e4de23e91747 Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Fri, 1 May 2026 14:47:22 +0200 Subject: [PATCH 146/184] Add a11y-governance to community catalog (#2381) Co-authored-by: Your Name --- docs/community/presets.md | 1 + presets/catalog.community.json | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index 0bb7a7ab42..eeb110cbf9 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -7,6 +7,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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) | | 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) | | 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) | | Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 7031652bfd..7c19d176c5 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -3,6 +3,34 @@ "updated_at": "2026-04-27T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { + "a11y-governance": { + "name": "A11Y Governance", + "id": "a11y-governance", + "version": "0.2.0", + "description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, and inclusive-content governance to Spec Kit.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.2.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 9, + "commands": 3 + }, + "tags": [ + "a11y", + "accessibility", + "bilingual", + "wcag", + "inclusion" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, "aide-in-place": { "name": "AIDE In-Place Migration", "id": "aide-in-place", @@ -16,7 +44,9 @@ "license": "MIT", "requires": { "speckit_version": ">=0.2.0", - "extensions": ["aide"] + "extensions": [ + "aide" + ] }, "provides": { "templates": 2, From 6ee8a887e029a4401fa0196b5d23265e9d63a031 Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Fri, 1 May 2026 14:57:02 +0200 Subject: [PATCH 147/184] Add architecture-governance to community catalog (#2383) Co-authored-by: Your Name --- docs/community/presets.md | 1 + presets/catalog.community.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/community/presets.md b/docs/community/presets.md index eeb110cbf9..1c5fc437fb 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -9,6 +9,7 @@ The following community-contributed presets customize how Spec Kit behaves — o |--------|---------|----------|----------|-----| | 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) | | 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) | | 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) | | Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 7c19d176c5..a1e4da5208 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -59,6 +59,34 @@ "aide" ] }, + "architecture-governance": { + "name": "Architecture Governance", + "id": "architecture-governance", + "version": "0.2.0", + "description": "Adds secure architecture governance, threat modeling, STRIDE/CAPEC, Zero Trust, S-ADRs, and OWASP SAMM to Spec Kit.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.2.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 11, + "commands": 3 + }, + "tags": [ + "architecture", + "governance", + "threat-modeling", + "stride", + "zero-trust" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, "canon-core": { "name": "Canon Core", "id": "canon-core", From 4133c8a5433c1190339a364f2f83d82ba6368921 Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Fri, 1 May 2026 15:03:12 +0200 Subject: [PATCH 148/184] Add cross-platform-governance to community catalog (#2384) Co-authored-by: Your Name --- docs/community/presets.md | 1 + presets/catalog.community.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/community/presets.md b/docs/community/presets.md index 1c5fc437fb..56b0569276 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -12,6 +12,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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) | | Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) | +| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) | | Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) | | Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) | | iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index a1e4da5208..d67dd32294 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -138,6 +138,34 @@ "created_at": "2026-04-13T00:00:00Z", "updated_at": "2026-04-13T00:00:00Z" }, + "cross-platform-governance": { + "name": "Cross-Platform Governance", + "id": "cross-platform-governance", + "version": "0.1.0", + "description": "Adds Bash and PowerShell parity, dry-run/WhatIf parity, man-page expectations, and Verb-Noun Cmdlet discipline.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.1.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 8, + "commands": 3 + }, + "tags": [ + "cross-platform", + "bash", + "powershell", + "man-page", + "cmdlet" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, "explicit-task-dependencies": { "name": "Explicit Task Dependencies", "id": "explicit-task-dependencies", From de9d98683aed54f46020ab6e402eb206e2b8e9b9 Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Fri, 1 May 2026 15:17:12 +0200 Subject: [PATCH 149/184] Add security-governance to community catalog (#2386) Co-authored-by: Your Name --- docs/community/presets.md | 1 + presets/catalog.community.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/community/presets.md b/docs/community/presets.md index 56b0569276..15f2b7c9ff 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -20,6 +20,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) | | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) | +| Security Governance | Adds secure development governance: memory-safe-language preference, secure code generation, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/VEX/SLSA, OpenSSF Scorecard, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index d67dd32294..8064bfc960 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -373,6 +373,34 @@ "created_at": "2026-04-23T08:00:00Z", "updated_at": "2026-04-23T08:00:00Z" }, + "security-governance": { + "name": "Security Governance", + "id": "security-governance", + "version": "0.2.0", + "description": "Adds secure development governance, MSL preference, ASVS verification, supply-chain transparency, and EU CRA awareness.", + "author": "Thorsten Hindermann", + "repository": "https://github.com/hindermath/spec-kit-preset-security-governance", + "download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.2.0.zip", + "homepage": "https://github.com/hindermath/spec-kit-preset-security-governance", + "documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 12, + "commands": 3 + }, + "tags": [ + "security", + "governance", + "msl", + "asvs", + "supply-chain" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, "toc-navigation": { "name": "Table of Contents Navigation", "id": "toc-navigation", From cc6f203dd9911d51b744543617d18ff7846094e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 08:56:13 -0500 Subject: [PATCH 150/184] chore(deps): bump DavidAnson/markdownlint-cli2-action (#2425) Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 23.0.0 to 23.1.0. - [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases) - [Commits](https://github.com/davidanson/markdownlint-cli2-action/compare/ce4853d43830c74c1753b39f3cf40f71c2031eb9...6b51ade7a9e4a75a7ad929842dd298a3804ebe8b) --- updated-dependencies: - dependency-name: DavidAnson/markdownlint-cli2-action dependency-version: 23.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fdece63093..8b11ccdfff 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v6 - name: Run markdownlint-cli2 - uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 # v23 + uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23 with: globs: | '**/*.md' From bb8fd5076340a3264f6bef6288580300b1b47d51 Mon Sep 17 00:00:00 2001 From: Ismael <712805+ismaelJimenez@users.noreply.github.com> Date: Fri, 1 May 2026 17:13:31 +0200 Subject: [PATCH 151/184] fix(specify): correct self-referencing step number in validation flow (#2152) --- templates/commands/specify.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 1f3f5c4465..cafa32f4e2 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -183,7 +183,7 @@ Given that feature description, do this: c. **Handle Validation Results**: - - **If all items pass**: Mark checklist complete and proceed to step 7 + - **If all items pass**: Mark checklist complete and proceed to step 8 - **If items fail (excluding [NEEDS CLARIFICATION])**: 1. List the failing items and specific issues From fcd6a80a07ffe2469b89ccd78ce95392a881301f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 1 May 2026 10:17:58 -0500 Subject: [PATCH 152/184] chore: release 0.8.4, begin 0.8.5.dev0 development (#2431) * chore: bump version to 0.8.4 * chore: begin 0.8.5.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d2c87cb7..48db19ddf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.8.4] - 2026-05-01 + +### Changed + +- fix(specify): correct self-referencing step number in validation flow (#2152) +- chore(deps): bump DavidAnson/markdownlint-cli2-action (#2425) +- Add security-governance to community catalog (#2386) +- Add cross-platform-governance to community catalog (#2384) +- Add architecture-governance to community catalog (#2383) +- Add a11y-governance to community catalog (#2381) +- feat(extensions): add Spec2Cloud extension for Azure deployment workflow (#2412) +- fix: migrate extension commands on integration switch (#2404) +- feat: add Squad Bridge extension to community catalog (#2417) +- chore: release 0.8.3, begin 0.8.4.dev0 development (#2418) + ## [0.8.3] - 2026-04-29 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 2c5980d38f..98920d8549 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.8.4.dev0" +version = "0.8.5.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 63cad6ace6d18e1f11ce51671e10545bb7a67e63 Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Fri, 1 May 2026 17:33:22 +0200 Subject: [PATCH 153/184] chore(integrations): clean up docs and project guard (#2428) --- AGENTS.md | 77 ++++------------------------------ src/specify_cli/__init__.py | 65 +++++++--------------------- tests/integrations/test_cli.py | 29 +++++++++++++ 3 files changed, 53 insertions(+), 118 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7adfd1d12e..d711b4214d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,23 +20,17 @@ src/specify_cli/integrations/ ├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration ├── manifest.py # IntegrationManifest (file tracking) ├── claude/ # Example: SkillsIntegration subclass -│ ├── __init__.py # ClaudeIntegration class -│ └── scripts/ # Thin wrapper scripts -│ ├── update-context.sh -│ └── update-context.ps1 +│ └── __init__.py # ClaudeIntegration class ├── gemini/ # Example: TomlIntegration subclass -│ ├── __init__.py -│ └── scripts/ +│ └── __init__.py ├── windsurf/ # Example: MarkdownIntegration subclass -│ ├── __init__.py -│ └── scripts/ +│ └── __init__.py ├── copilot/ # Example: IntegrationBase subclass (custom setup) -│ ├── __init__.py -│ └── scripts/ +│ └── __init__.py └── ... # One subpackage per supported agent ``` -The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, and capabilities are derived from the integration classes for the Python integration layer. However, context-update behavior still requires explicit cases in the shared dispatcher scripts (`scripts/bash/update-agent-context.sh` and `scripts/powershell/update-agent-context.ps1`), which currently maintain their own supported-agent lists and agent-key→context-file mappings until they are migrated to registry-based dispatch. +The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, capabilities, and context files are derived from the integration classes for the Python integration layer. --- @@ -179,63 +173,11 @@ def _register_builtins() -> None: # ... ``` -### 4. Add scripts +### 4. Context file behavior -Create two thin wrapper scripts in `src/specify_cli/integrations//scripts/` that delegate to the shared context-update scripts. Each is ~25 lines of boilerplate. +Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate. -> **Note on `` vs ``:** `` is the Python-safe directory name for your integration — it matches `` exactly when the key contains no hyphens (e.g., key `"gemini"` → `gemini/`), but uses underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value (e.g., `key = "kiro-cli"`), since that is what the CLI and registry use. - -**`update-context.sh`:** - -```bash -#!/usr/bin/env bash -# update-context.sh — integration: create/update -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" -``` - -**`update-context.ps1`:** - -```powershell -# update-context.ps1 — integration: create/update -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType -``` - -Replace `` with your integration key and `` / `` with the appropriate values. - -You must also add the agent to the shared context-update scripts so the shared dispatcher recognises the new key: - -- **`scripts/bash/update-agent-context.sh`** — add a file-path variable and a case in `update_specific_agent()`. -- **`scripts/powershell/update-agent-context.ps1`** — add a file-path variable, add the new key to the `AgentType` parameter's `[ValidateSet(...)]`, add a switch case in `Update-SpecificAgent`, and add an entry in `Update-AllExistingAgents`. +Only add custom setup logic when the agent needs non-standard behavior. Most integrations do not need wrapper scripts or separate context-update dispatch code. ### 5. Test it @@ -422,7 +364,6 @@ Implementation: Extends `MarkdownIntegration` with custom `setup()` method that: 3. Applies Forge-specific transformations via `_apply_forge_transformations()` 4. Strips `handoffs` frontmatter key 5. Injects missing `name` fields -6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` and lists `forge` in their usage/help text ### Goose Integration @@ -436,7 +377,7 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): 2. Extracts title and description from frontmatter 3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt) 4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping -5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge) +5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there ## Common Pitfalls diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 71af7ca09f..f954d43ed4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1968,6 +1968,16 @@ def _resolve_script_type(project_root: Path, script_type: str | None) -> str: return "ps" if os.name == "nt" else "sh" +def _require_specify_project() -> Path: + """Return the current project root if it is a spec-kit project, else exit.""" + project_root = Path.cwd() + if (project_root / ".specify").is_dir(): + return project_root + console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") + console.print("Run this command from a spec-kit project root") + raise typer.Exit(1) + + @integration_app.command("list") def integration_list( catalog: bool = typer.Option(False, "--catalog", help="Browse full catalog (built-in + community)"), @@ -1975,14 +1985,7 @@ def integration_list( """List available integrations and installed status.""" from .integrations import INTEGRATION_REGISTRY - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() current = _read_integration_json(project_root) installed_key = current.get("integration") @@ -2069,14 +2072,7 @@ def integration_install( from .integrations import INTEGRATION_REGISTRY, get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() integration = get_integration(key) if integration is None: console.print(f"[red]Error:[/red] Unknown integration '{key}'") @@ -2220,14 +2216,7 @@ def integration_uninstall( from .integrations import get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() current = _read_integration_json(project_root) installed_key = current.get("integration") @@ -2309,14 +2298,7 @@ def integration_switch( from .integrations import INTEGRATION_REGISTRY, get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() target_integration = get_integration(target) if target_integration is None: console.print(f"[red]Error:[/red] Unknown integration '{target}'") @@ -2471,14 +2453,7 @@ def integration_upgrade( from .integrations import get_integration from .integrations.manifest import IntegrationManifest - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() current = _read_integration_json(project_root) installed_key = current.get("integration") @@ -2583,16 +2558,6 @@ def integration_upgrade( # not additive like extensions and presets. -def _require_specify_project() -> Path: - """Return the current project root if it is a spec-kit project, else exit.""" - project_root = Path.cwd() - if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - return project_root - - @integration_app.command("search") def integration_search( query: Optional[str] = typer.Argument(None, help="Search query (optional)"), diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 60e51a5fb9..95dcf206e8 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -706,6 +706,35 @@ def test_catalog_list_requires_specify_project(self, tmp_path): assert result.exit_code == 1 assert "Not a spec-kit project" in result.output + def test_primary_integration_commands_require_specify_project(self, tmp_path): + project = tmp_path / "bare" + project.mkdir() + commands = [ + ["integration", "list"], + ["integration", "install", "codex"], + ["integration", "uninstall"], + ["integration", "switch", "codex"], + ["integration", "upgrade"], + ] + + for command in commands: + result = self._invoke(command, project) + failure_context = ( + f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}" + ) + assert result.exit_code == 1, failure_context + assert "Not a spec-kit project" in result.output, failure_context + + def test_integration_commands_require_specify_directory(self, tmp_path): + project = tmp_path / "bad" + project.mkdir() + (project / ".specify").write_text("not a directory") + + result = self._invoke(["integration", "list"], project) + + assert result.exit_code == 1, result.output + assert "Not a spec-kit project" in result.output + # -- search ------------------------------------------------------------ def test_search_lists_all(self, tmp_path, monkeypatch): From 38fd1f6cc234b9e5cb296341db0b2a35866aaa4c Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Fri, 1 May 2026 18:54:41 +0200 Subject: [PATCH 154/184] Support controlled multi-install for safe AI agent integrations (#2389) * support controlled multi-install integrations * fix: harden multi-install integration state * refactor: isolate integration runtime helpers * fix: address copilot review feedback * fix: address follow-up copilot feedback * fix: tighten integration switch semantics * fix: address final copilot review feedback * fix: harden integration manifest read errors * fix: refuse symlinked shared infra paths * test: filter expected self-test preset warning * test: address copilot review nits * refactor: centralize safe shared infra writes * fix: use no-follow writes for shared infra * fix: keep default integration atomic on template refresh * fix: harden shared infra error paths * fix: preflight shared infra and future state schemas * fix: support nested shared scripts during preflight * test: tolerate wrapped schema error output * fix: use safe default mode for shared text writes * fix: use posix paths in shared skip output * fix: share project guard for integration use * fix: centralize spec-kit project guards * fix: use posix project paths in cli output * fix: harden shared manifest and upgrade refresh --- docs/reference/integrations.md | 63 +- src/specify_cli/__init__.py | 1078 ++++++++++------- src/specify_cli/integration_runtime.py | 90 ++ src/specify_cli/integration_state.py | 161 +++ .../integrations/auggie/__init__.py | 1 + src/specify_cli/integrations/base.py | 8 + .../integrations/claude/__init__.py | 1 + .../integrations/codebuddy/__init__.py | 1 + .../integrations/codex/__init__.py | 1 + .../integrations/cursor_agent/__init__.py | 1 + .../integrations/gemini/__init__.py | 1 + .../integrations/iflow/__init__.py | 1 + .../integrations/junie/__init__.py | 1 + .../integrations/kilocode/__init__.py | 1 + src/specify_cli/integrations/kimi/__init__.py | 1 + src/specify_cli/integrations/manifest.py | 69 +- .../integrations/qodercli/__init__.py | 1 + src/specify_cli/integrations/qwen/__init__.py | 1 + src/specify_cli/integrations/roo/__init__.py | 1 + src/specify_cli/integrations/shai/__init__.py | 1 + .../integrations/tabnine/__init__.py | 1 + src/specify_cli/integrations/trae/__init__.py | 1 + .../integrations/windsurf/__init__.py | 1 + src/specify_cli/shared_infra.py | 317 +++++ tests/integrations/test_cli.py | 401 +++++- .../integrations/test_integration_catalog.py | 2 +- tests/integrations/test_integration_state.py | 86 ++ .../test_integration_subcommand.py | 510 +++++++- tests/integrations/test_registry.py | 203 ++++ tests/test_presets.py | 67 +- 30 files changed, 2592 insertions(+), 480 deletions(-) create mode 100644 src/specify_cli/integration_runtime.py create mode 100644 src/specify_cli/integration_state.py create mode 100644 src/specify_cli/shared_infra.py create mode 100644 tests/integrations/test_integration_state.py diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index e1b8e60a8b..d3f9fc6282 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -43,6 +43,8 @@ specify integration list ``` Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based. +When multiple integrations are installed, the list marks the default integration separately from the other installed integrations. +The list also shows whether each built-in integration is declared multi-install safe. ## Install an Integration @@ -53,9 +55,12 @@ specify integration install | Option | Description | | ------------------------ | ------------------------------------------------------------------------ | | `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--force` | Opt in to installing alongside integrations that are not declared multi-install safe | | `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | -Installs the specified integration into the current project. Fails if another integration is already installed — use `switch` instead. If the installation fails partway through, it automatically rolls back to a clean state. +Installs the specified integration into the current project. If another integration is already installed, the command only proceeds automatically when all involved integrations are declared multi-install safe. Otherwise, use `switch` to replace the default integration or pass `--force` to explicitly opt in to multi-install. If the installation fails partway through, it automatically rolls back to a clean state. + +Installing an additional integration does not change the default integration. Use `specify integration use ` to change the default. > **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init --integration ` instead. @@ -84,10 +89,22 @@ specify integration switch | Option | Description | | ------------------------ | ------------------------------------------------------------------------ | | `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | -| `--force` | Force removal of modified files during uninstall | -| `--integration-options` | Options for the target integration | +| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default | +| `--integration-options` | Options for the target integration when it is not already installed | + +If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade --integration-options ...` first, then `use `. + +## Use an Installed Integration + +```bash +specify integration use +``` -Equivalent to running `uninstall` followed by `install` in a single step. +| Option | Description | +| --------- | --------------------------------------------------- | +| `--force` | Overwrite managed shared templates while changing the default | + +Sets the default integration without uninstalling any other installed integrations. This also refreshes managed shared templates so command references match the new default integration's invocation style. Modified or untracked shared templates are preserved unless `--force` is used. ## Upgrade an Integration @@ -101,7 +118,7 @@ specify integration upgrade [] | `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | | `--integration-options` | Options for the integration | -Reinstalls the current integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the currently installed integration; if a key is provided, it must match the installed one — otherwise the command fails and suggests using `switch` instead. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. +Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration. ## Integration-Specific Options @@ -120,9 +137,39 @@ specify integration install generic --integration-options="--commands-dir .myage ## FAQ -### Can I use multiple integrations at the same time? +### Can I install multiple integrations in the same project? + +Yes, but it is intended for team portability rather than the default workflow. Multiple integrations are allowed automatically only when the installed integration and the new integration are declared multi-install safe by Spec Kit. For other combinations, pass `--force` to acknowledge that multiple agents may see unrelated agent-specific instructions or commands. + +Spec Kit tracks one default integration in `.specify/integration.json` with `default_integration`, all installed integrations with `installed_integrations`, per-integration runtime settings with `integration_settings`, and a dedicated `integration_state_schema` for future state migrations. The legacy `integration` field remains as an alias for the default integration. + +### Which integrations are multi-install safe? + +An integration is multi-install safe when it uses isolated agent directories, a dedicated context file that does not collide with another safe integration, stable command invocation settings, and a separate install manifest. Shared Spec Kit templates remain aligned to the single default integration. + +The currently declared multi-install safe integrations are: + +| Key | Isolation | +| --- | --------- | +| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` | +| `claude` | `.claude/skills`, `CLAUDE.md` | +| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` | +| `codex` | `.agents/skills`, `AGENTS.md` | +| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` | +| `gemini` | `.gemini/commands`, `GEMINI.md` | +| `iflow` | `.iflow/commands`, `IFLOW.md` | +| `junie` | `.junie/commands`, `.junie/AGENTS.md` | +| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` | +| `kimi` | `.kimi/skills`, `KIMI.md` | +| `qodercli` | `.qoder/commands`, `QODER.md` | +| `qwen` | `.qwen/commands`, `QWEN.md` | +| `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` | +| `shai` | `.shai/commands`, `SHAI.md` | +| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` | +| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` | +| `windsurf` | `.windsurf/workflows`, `.windsurf/rules/specify-rules.md` | -No. Only one AI coding agent integration can be installed per project. Use `specify integration switch ` to change to a different AI coding agent. +Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`. ### What happens to my changes when I uninstall or switch? @@ -138,4 +185,4 @@ CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be ins ### When should I use `upgrade` vs `switch`? -Use `upgrade` when you've upgraded Spec Kit and want to refresh the same integration's templates. Use `switch` when you want to change to a different AI coding agent. +Use `upgrade` when you've upgraded Spec Kit and want to refresh an installed integration's managed files. Use `switch` when you want to replace the current default with another integration; if the target is already installed, `switch` behaves like `use`. diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f954d43ed4..6d0181091d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -54,6 +54,27 @@ from rich.tree import Tree from typer.core import TyperGroup +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, + dedupe_integration_keys as _dedupe_integration_keys, + default_integration_key as _default_integration_key, + installed_integration_keys as _installed_integration_keys, + integration_setting as _integration_setting, + integration_settings as _integration_settings, + normalize_integration_state as _normalize_integration_state, + write_integration_json as _write_integration_json_file, +) +from .shared_infra import ( + install_shared_infra as _install_shared_infra_impl, + refresh_shared_templates as _refresh_shared_templates_impl, +) + # For cross-platform keyboard input import readchar @@ -643,6 +664,11 @@ def _locate_core_pack() -> Path | None: return None +def _repo_root() -> Path: + """Return the source checkout root used for editable installs.""" + return Path(__file__).parent.parent.parent + + def _locate_bundled_extension(extension_id: str) -> Path | None: """Return the path to a bundled extension, or None. @@ -660,8 +686,7 @@ def _locate_bundled_extension(extension_id: str) -> Path | None: return candidate # Source-checkout / editable install: look relative to repo root - repo_root = Path(__file__).parent.parent.parent - candidate = repo_root / "extensions" / extension_id + candidate = _repo_root() / "extensions" / extension_id if (candidate / "extension.yml").is_file(): return candidate @@ -685,8 +710,7 @@ def _locate_bundled_workflow(workflow_id: str) -> Path | None: return candidate # Source-checkout / editable install: look relative to repo root - repo_root = Path(__file__).parent.parent.parent - candidate = repo_root / "workflows" / workflow_id + candidate = _repo_root() / "workflows" / workflow_id if (candidate / "workflow.yml").is_file(): return candidate @@ -710,14 +734,31 @@ def _locate_bundled_preset(preset_id: str) -> Path | None: return candidate # Source-checkout / editable install: look relative to repo root - repo_root = Path(__file__).parent.parent.parent - candidate = repo_root / "presets" / preset_id + candidate = _repo_root() / "presets" / preset_id if (candidate / "preset.yml").is_file(): return candidate return None +def _refresh_shared_templates( + project_path: Path, + *, + invoke_separator: str, + force: bool = False, +) -> None: + """Refresh default-sensitive shared templates without touching scripts.""" + _refresh_shared_templates_impl( + project_path, + version=get_speckit_version(), + core_pack=_locate_core_pack(), + repo_root=_repo_root(), + console=console, + invoke_separator=invoke_separator, + force=force, + ) + + def _install_shared_infra( project_path: Path, script_type: str, @@ -741,79 +782,36 @@ def _install_shared_infra( Returns ``True`` on success. """ - from .integrations.base import IntegrationBase - from .integrations.manifest import IntegrationManifest - - core = _locate_core_pack() - manifest = IntegrationManifest("speckit", project_path, version=get_speckit_version()) + return _install_shared_infra_impl( + project_path, + script_type, + version=get_speckit_version(), + core_pack=_locate_core_pack(), + repo_root=_repo_root(), + console=console, + force=force, + invoke_separator=invoke_separator, + ) - # Scripts - if core and (core / "scripts").is_dir(): - scripts_src = core / "scripts" - else: - repo_root = Path(__file__).parent.parent.parent - scripts_src = repo_root / "scripts" - - skipped_files: list[str] = [] - - if scripts_src.is_dir(): - dest_scripts = project_path / ".specify" / "scripts" - dest_scripts.mkdir(parents=True, exist_ok=True) - variant_dir = "bash" if script_type == "sh" else "powershell" - variant_src = scripts_src / variant_dir - if variant_src.is_dir(): - dest_variant = dest_scripts / variant_dir - dest_variant.mkdir(parents=True, exist_ok=True) - for src_path in variant_src.rglob("*"): - if src_path.is_file(): - rel_path = src_path.relative_to(variant_src) - dst_path = dest_variant / rel_path - if dst_path.exists() and not force: - skipped_files.append(str(dst_path.relative_to(project_path))) - else: - dst_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src_path, dst_path) - rel = dst_path.relative_to(project_path).as_posix() - manifest.record_existing(rel) - - # Page templates (not command templates, not vscode-settings.json) - if core and (core / "templates").is_dir(): - templates_src = core / "templates" - else: - repo_root = Path(__file__).parent.parent.parent - templates_src = repo_root / "templates" - - if templates_src.is_dir(): - dest_templates = project_path / ".specify" / "templates" - dest_templates.mkdir(parents=True, exist_ok=True) - for f in templates_src.iterdir(): - if f.is_file() and f.name != "vscode-settings.json" and not f.name.startswith("."): - dst = dest_templates / f.name - if dst.exists() and not force: - skipped_files.append(str(dst.relative_to(project_path))) - else: - content = f.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs( - content, invoke_separator - ) - dst.write_text(content, encoding="utf-8") - rel = dst.relative_to(project_path).as_posix() - manifest.record_existing(rel) - if skipped_files: - console.print( - f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:" - ) - for f in skipped_files: - console.print(f" {f}") - console.print( - "To refresh shared infrastructure, run " - "[cyan]specify init --here --force[/cyan] or " - "[cyan]specify integration upgrade --force[/cyan]." +def _install_shared_infra_or_exit( + project_path: Path, + script_type: str, + tracker: StepTracker | None = None, + force: bool = False, + invoke_separator: str = ".", +) -> bool: + try: + return _install_shared_infra( + project_path, + script_type, + tracker=tracker, + force=force, + invoke_separator=invoke_separator, ) - - manifest.save() - return True + except (ValueError, OSError) as exc: + console.print(f"[red]Error:[/red] Failed to install shared infrastructure: {exc}") + raise typer.Exit(1) def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = None) -> None: @@ -855,7 +853,7 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = os.chmod(script, new_mode) updated += 1 except Exception as e: - failures.append(f"{script.relative_to(project_path)}: {e}") + failures.append(f"{_display_project_path(project_path, script)}: {e}") if tracker: detail = f"{updated} updated" + (f", {len(failures)} failed" if failures else "") tracker.add("chmod", "Set script permissions recursively") @@ -1299,19 +1297,32 @@ def init( ) manifest.save() - # Write .specify/integration.json - integration_json = project_path / ".specify" / "integration.json" - integration_json.parent.mkdir(parents=True, exist_ok=True) - integration_json.write_text(json.dumps({ - "integration": resolved_integration.key, - "version": get_speckit_version(), - }, indent=2) + "\n", encoding="utf-8") + integration_settings = _with_integration_setting( + {}, + resolved_integration.key, + resolved_integration, + script_type=selected_script, + raw_options=integration_options, + parsed_options=integration_parsed_options or None, + ) + _write_integration_json( + project_path, + resolved_integration.key, + [resolved_integration.key], + integration_settings, + ) tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) # Install shared infrastructure (scripts, templates) tracker.start("shared-infra") - _install_shared_infra(project_path, selected_script, tracker=tracker, force=force, invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options)) + _install_shared_infra_or_exit( + project_path, + selected_script, + tracker=tracker, + force=force, + invoke_separator=resolved_integration.effective_invoke_separator(integration_parsed_options), + ) tracker.complete("shared-infra", f"scripts ({selected_script}) + templates") ensure_constitution_from_template(project_path, tracker=tracker) @@ -1869,7 +1880,7 @@ def get_speckit_version() -> str: # Fallback: try reading from pyproject.toml try: import tomllib - pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" + pyproject_path = _repo_root() / "pyproject.toml" if pyproject_path.exists(): with open(pyproject_path, "rb") as f: data = tomllib.load(f) @@ -1898,11 +1909,8 @@ def get_speckit_version() -> str: integration_app.add_typer(integration_catalog_app, name="catalog") -INTEGRATION_JSON = ".specify/integration.json" - - def _read_integration_json(project_root: Path) -> dict[str, Any]: - """Load ``.specify/integration.json``. Returns ``{}`` when missing.""" + """Load ``.specify/integration.json``. Returns normalized state when present.""" path = project_root / INTEGRATION_JSON if not path.exists(): return {} @@ -1922,20 +1930,42 @@ def _read_integration_json(project_root: Path) -> dict[str, Any]: console.print(f"[red]Error:[/red] {path} must contain a JSON object, got {type(data).__name__}.") console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.") raise typer.Exit(1) - return data + schema = data.get("integration_state_schema") + if isinstance(schema, int) and not isinstance(schema, bool) and schema > INTEGRATION_STATE_SCHEMA: + console.print( + f"[red]Error:[/red] {path} uses integration state schema {schema}, " + f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}." + ) + console.print("Please upgrade Spec Kit before modifying integrations.") + raise typer.Exit(1) + return _normalize_integration_state(data) def _write_integration_json( project_root: Path, - integration_key: str, + integration_key: str | None, + installed_integrations: list[str] | None = None, + integration_settings: dict[str, dict[str, Any]] | None = None, ) -> None: - """Write ``.specify/integration.json`` for *integration_key*.""" - dest = project_root / INTEGRATION_JSON - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_text(json.dumps({ - "integration": integration_key, - "version": get_speckit_version(), - }, indent=2) + "\n", encoding="utf-8") + """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, + ) + + +def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: + """Clear active integration keys from init-options.json when they match.""" + opts = load_init_options(project_root) + 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) + opts.pop("context_file", None) + save_init_options(project_root, opts) def _remove_integration_json(project_root: Path) -> None: @@ -1945,6 +1975,13 @@ def _remove_integration_json(project_root: Path) -> None: path.unlink() +_MANIFEST_READ_ERRORS = (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) + + +class _SharedTemplateRefreshError(RuntimeError): + """Raised when default integration metadata should not be persisted.""" + + 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() @@ -1968,6 +2005,102 @@ def _resolve_script_type(project_root: Path, script_type: str | None) -> str: 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) + + +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 _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, +) -> None: + """Persist *key* as default and align active runtime metadata.""" + 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: + _refresh_shared_templates( + project_root, + invoke_separator=_invoke_separator_for_integration( + integration, {"integration_settings": settings}, key, parsed_options + ), + force=refresh_templates_force, + ) + except (ValueError, OSError) as exc: + raise _SharedTemplateRefreshError( + f"Failed to refresh shared templates 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) + + +def _display_project_path(project_root: Path, path: str | Path) -> str: + """Return a stable POSIX-style display path for paths under a project.""" + path_obj = Path(path) + try: + rel_path = path_obj.relative_to(project_root) if path_obj.is_absolute() else path_obj + except ValueError: + try: + rel_path = path_obj.resolve().relative_to(project_root.resolve()) + except (OSError, ValueError): + return path_obj.as_posix() + return rel_path.as_posix() + + def _require_specify_project() -> Path: """Return the current project root if it is a spec-kit project, else exit.""" project_root = Path.cwd() @@ -1987,7 +2120,8 @@ def integration_list( project_root = _require_specify_project() current = _read_integration_json(project_root) - installed_key = current.get("integration") + default_key = _default_integration_key(current) + installed_keys = set(_installed_integration_keys(current)) if catalog: from .integrations.catalog import IntegrationCatalog, IntegrationCatalogError @@ -2009,12 +2143,15 @@ def integration_list( 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 == installed_key: + 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" @@ -2022,12 +2159,16 @@ def integration_list( status = "discovery-only" else: status = "" + safe = "" + if eid in INTEGRATION_REGISTRY: + safe = "yes" if getattr(INTEGRATION_REGISTRY[eid], "multi_install_safe", False) else "no" table.add_row( eid, entry.get("name", eid), entry.get("version", ""), cat_name, status, + safe, ) console.print(table) @@ -2038,6 +2179,7 @@ def integration_list( 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] @@ -2045,18 +2187,22 @@ def integration_list( name = cfg.get("name", key) requires_cli = cfg.get("requires_cli", False) - if key == installed_key: + 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)" - table.add_row(key, name, status, cli_req) + 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_key: - console.print(f"\n[dim]Current integration:[/dim] [cyan]{installed_key}[/cyan]") + 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 [/cyan]") @@ -2066,6 +2212,7 @@ def integration_list( 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.""" @@ -2081,30 +2228,68 @@ def integration_install( raise typer.Exit(1) current = _read_integration_json(project_root) - installed_key = current.get("integration") + default_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) - if installed_key and installed_key == key: + if key in installed_keys: console.print(f"[yellow]Integration '{key}' is already installed.[/yellow]") - console.print("Run [cyan]specify integration uninstall[/cyan] first, then reinstall.") + console.print( + f"Run [cyan]specify integration upgrade {key}[/cyan] to reinstall managed files, " + f"or [cyan]specify integration uninstall {key}[/cyan] first." + ) raise typer.Exit(0) - if installed_key: - console.print(f"[red]Error:[/red] Integration '{installed_key}' is already installed.") - console.print(f"Run [cyan]specify integration uninstall[/cyan] first, or use [cyan]specify integration switch {key}[/cyan].") - raise typer.Exit(1) + 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"Run [cyan]specify integration switch {key}[/cyan] to replace the default " + f"integration, or retry with [cyan]--force[/cyan] to opt in." + ) + 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. - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(integration, integration_options) + 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). - _install_shared_infra(project_root, selected_script, invoke_separator=integration.effective_invoke_separator(parsed_options)) + 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": ensure_executable_scripts(project_root) @@ -2117,11 +2302,22 @@ def integration_install( project_root, manifest, parsed_options=parsed_options, script_type=selected_script, - raw_options=integration_options, + raw_options=raw_options, ) manifest.save() - _write_integration_json(project_root, integration.key) - _update_init_options_for_integration(project_root, integration, script_type=selected_script) + 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) except Exception as e: # Attempt rollback of any files written by setup @@ -2130,12 +2326,19 @@ def integration_install( except Exception as rollback_err: # Suppress so the original setup error remains the primary failure console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration changes: {rollback_err}") - _remove_integration_json(project_root) + 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 install integration: {e}") 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]") def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: @@ -2207,6 +2410,44 @@ def _update_init_options_for_integration( save_init_options(project_root, opts) +@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 managed shared templates while changing the default"), +): + """Set the default integration without uninstalling other integrations.""" + from .integrations import get_integration + + 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 [/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, + ) + console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].") + + @integration_app.command("uninstall") def integration_uninstall( key: str = typer.Argument(None, help="Integration key to uninstall (default: current integration)"), @@ -2218,16 +2459,17 @@ def integration_uninstall( project_root = _require_specify_project() current = _read_integration_json(project_root) - installed_key = current.get("integration") + default_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) if key is None: - if not installed_key: + if not default_key: console.print("[yellow]No integration is currently installed.[/yellow]") raise typer.Exit(0) - key = installed_key + key = default_key - if installed_key and installed_key != key: - console.print(f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{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) @@ -2235,20 +2477,35 @@ def integration_uninstall( 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]") - _remove_integration_json(project_root) - # Clear integration-related keys from init-options.json - opts = load_init_options(project_root) - if opts.get("integration") == key or opts.get("ai") == key: - opts.pop("integration", None) - opts.pop("ai", None) - opts.pop("ai_skills", None) - opts.pop("context_file", None) - save_init_options(project_root, opts) + 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 (ValueError, FileNotFoundError) as exc: + 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( @@ -2265,16 +2522,31 @@ def integration_uninstall( if integration: integration.remove_context_section(project_root) - _remove_integration_json(project_root) + 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) - # Update init-options.json to clear the integration - opts = load_init_options(project_root) - if opts.get("integration") == key or opts.get("ai") == key: - opts.pop("integration", None) - opts.pop("ai", None) - opts.pop("ai_skills", None) - opts.pop("context_file", None) - save_init_options(project_root, opts) + 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") @@ -2283,7 +2555,7 @@ def integration_uninstall( if skipped: console.print(f"\n[yellow]⚠[/yellow] {len(skipped)} modified file(s) were preserved:") for path in skipped: - rel = path.relative_to(project_root) if path.is_absolute() else path + rel = _display_project_path(project_root, path) console.print(f" {rel}") @@ -2307,10 +2579,67 @@ def integration_switch( raise typer.Exit(1) current = _read_integration_json(project_root) - installed_key = current.get("integration") + installed_keys = _installed_integration_keys(current) + installed_key = _default_integration_key(current) if installed_key == target: - console.print(f"[yellow]Integration '{target}' is already installed. Nothing to switch.[/yellow]") + 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]; " + "managed shared templates 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) @@ -2324,7 +2653,7 @@ def integration_switch( console.print(f"Uninstalling current integration: [cyan]{installed_key}[/cyan]") try: old_manifest = IntegrationManifest.load(installed_key, project_root) - except (ValueError, FileNotFoundError) as exc: + 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( @@ -2348,7 +2677,7 @@ def integration_switch( console.print(f" Removed {len(removed)} file(s)") if skipped: console.print(f" [yellow]⚠[/yellow] {len(skipped)} modified file(s) preserved") - except (ValueError, FileNotFoundError) as exc: + 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.") @@ -2372,24 +2701,48 @@ def integration_switch( ) # Clear metadata so a failed Phase 2 doesn't leave stale references - _remove_integration_json(project_root) - opts = load_init_options(project_root) - opts.pop("integration", None) - opts.pop("ai", None) - opts.pop("ai_skills", None) - opts.pop("context_file", None) - save_init_options(project_root, opts) + 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. - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(target_integration, integration_options) + raw_options, parsed_options = _resolve_integration_options( + target_integration, current, target, integration_options + ) # Ensure shared infrastructure is present (safe to run unconditionally; # _install_shared_infra merges missing files without overwriting). - _install_shared_infra(project_root, selected_script, invoke_separator=target_integration.effective_invoke_separator(parsed_options)) + _install_shared_infra_or_exit( + project_root, + selected_script, + invoke_separator=_invoke_separator_for_integration( + target_integration, current, target, parsed_options + ), + ) if os.name != "nt": ensure_executable_scripts(project_root) @@ -2404,11 +2757,19 @@ def integration_switch( project_root, manifest, parsed_options=parsed_options, script_type=selected_script, - raw_options=integration_options, + raw_options=raw_options, ) manifest.save() - _write_integration_json(project_root, target_integration.key) - _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) + _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. @@ -2430,7 +2791,34 @@ def integration_switch( except Exception as rollback_err: # Suppress so the original setup error remains the primary failure console.print(f"[yellow]Warning:[/yellow] Failed to roll back integration '{target}': {rollback_err}") - _remove_integration_json(project_root) + 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 install integration '{target}': {e}") raise typer.Exit(1) @@ -2455,7 +2843,8 @@ def integration_upgrade( project_root = _require_specify_project() current = _read_integration_json(project_root) - installed_key = current.get("integration") + installed_key = _default_integration_key(current) + installed_keys = _installed_integration_keys(current) if key is None: if not installed_key: @@ -2463,11 +2852,8 @@ def integration_upgrade( raise typer.Exit(0) key = installed_key - if installed_key and installed_key != key: - console.print( - f"[red]Error:[/red] Integration '{key}' is not the currently installed integration ('{installed_key}')." - ) - console.print(f"Use [cyan]specify integration switch {key}[/cyan] instead.") + 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) @@ -2483,7 +2869,7 @@ def integration_upgrade( try: old_manifest = IntegrationManifest.load(key, project_root) - except (ValueError, FileNotFoundError) as exc: + except _MANIFEST_READ_ERRORS as exc: console.print(f"[red]Error:[/red] Integration manifest for '{key}' is unreadable: {exc}") raise typer.Exit(1) @@ -2496,17 +2882,35 @@ def integration_upgrade( console.print("\nUse [cyan]--force[/cyan] to overwrite modified files, or resolve manually.") raise typer.Exit(1) - selected_script = _resolve_script_type(project_root, script) + 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. - parsed_options: dict[str, Any] | None = None - if integration_options: - parsed_options = _parse_integration_options(integration, integration_options) + raw_options, parsed_options = _resolve_integration_options( + integration, current, key, integration_options + ) # Ensure shared infrastructure is up to date; --force overwrites existing files. - _install_shared_infra(project_root, selected_script, force=force, invoke_separator=integration.effective_invoke_separator(parsed_options)) + 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": ensure_executable_scripts(project_root) @@ -2520,11 +2924,33 @@ def integration_upgrade( new_manifest, parsed_options=parsed_options, script_type=selected_script, - raw_options=integration_options, + 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: + _refresh_shared_templates( + project_root, + invoke_separator=_invoke_separator_for_integration( + integration, {"integration_settings": settings}, key, parsed_options + ), + force=force, + ) + except (ValueError, OSError) as exc: + raise _SharedTemplateRefreshError( + f"Failed to refresh shared templates for '{key}': {exc}" + ) from exc new_manifest.save() - _write_integration_json(project_root, key) - _update_init_options_for_integration(project_root, integration, script_type=selected_script) + _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) except Exception as exc: # Don't teardown — setup overwrites in-place, so teardown would # delete files that were working before the upgrade. Just report. @@ -2853,14 +3279,7 @@ def preset_list(): """List installed presets.""" from .presets import PresetManager - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() manager = PresetManager(project_root) installed = manager.list_installed() @@ -2899,14 +3318,7 @@ def preset_add( PresetCompatibilityError, ) - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() # Validate priority if priority < 1: console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") @@ -3020,14 +3432,7 @@ def preset_remove( """Remove an installed preset.""" from .presets import PresetManager - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() manager = PresetManager(project_root) if not manager.registry.is_installed(preset_id): @@ -3050,14 +3455,7 @@ def preset_search( """Search for presets in the catalog.""" from .presets import PresetCatalog, PresetError - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() catalog = PresetCatalog(project_root) try: @@ -3087,14 +3485,7 @@ def preset_resolve( """Show which template will be resolved for a given name.""" from .presets import PresetResolver - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() resolver = PresetResolver(project_root) layers = resolver.collect_all_layers(template_name) @@ -3158,14 +3549,7 @@ def preset_info( from .extensions import normalize_priority from .presets import PresetCatalog, PresetManager, PresetError - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() # Check if installed locally first manager = PresetManager(project_root) local_pack = manager.get_pack(preset_id) @@ -3232,15 +3616,7 @@ def preset_set_priority( """Set the resolution priority of an installed preset.""" from .presets import PresetManager - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() # Validate priority if priority < 1: console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") @@ -3283,15 +3659,7 @@ def preset_enable( """Enable a disabled preset.""" from .presets import PresetManager - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() manager = PresetManager(project_root) # Check if preset is installed @@ -3324,15 +3692,7 @@ def preset_disable( """Disable a preset without removing it.""" from .presets import PresetManager - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() manager = PresetManager(project_root) # Check if preset is installed @@ -3367,14 +3727,7 @@ def preset_catalog_list(): """List all active preset catalogs.""" from .presets import PresetCatalog, PresetValidationError - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() catalog = PresetCatalog(project_root) try: @@ -3407,7 +3760,7 @@ def preset_catalog_list(): except PresetValidationError: proj_loaded = False if proj_loaded: - console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]") else: try: user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None @@ -3436,13 +3789,8 @@ def preset_catalog_add( """Add a catalog to .specify/preset-catalogs.yml.""" from .presets import PresetCatalog, PresetValidationError - project_root = Path.cwd() - + project_root = _require_specify_project() specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) # Validate URL tmp_catalog = PresetCatalog(project_root) @@ -3459,7 +3807,8 @@ def preset_catalog_add( try: config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} except Exception as e: - console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") + config_label = _display_project_path(project_root, config_path) + console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}") raise typer.Exit(1) else: config = {} @@ -3491,7 +3840,7 @@ def preset_catalog_add( console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") console.print(f" URL: {url}") console.print(f" Priority: {priority}") - console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}") @preset_catalog_app.command("remove") @@ -3499,13 +3848,8 @@ def preset_catalog_remove( name: str = typer.Argument(help="Catalog name to remove"), ): """Remove a catalog from .specify/preset-catalogs.yml.""" - project_root = Path.cwd() - + project_root = _require_specify_project() specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) config_path = specify_dir / "preset-catalogs.yml" if not config_path.exists(): @@ -3668,15 +4012,7 @@ def extension_list( """List installed extensions.""" from .extensions import ExtensionManager - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() manager = ExtensionManager(project_root) installed = manager.list_installed() @@ -3709,14 +4045,7 @@ def catalog_list(): """List all active extension catalogs.""" from .extensions import ExtensionCatalog, ValidationError - project_root = Path.cwd() - - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() catalog = ExtensionCatalog(project_root) try: @@ -3749,7 +4078,7 @@ def catalog_list(): except ValidationError: proj_loaded = False if proj_loaded: - console.print(f"[dim]Config: {config_path.relative_to(project_root)}[/dim]") + console.print(f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]") else: try: user_loaded = user_config_path.exists() and catalog._load_catalog_config(user_config_path) is not None @@ -3778,13 +4107,8 @@ def catalog_add( """Add a catalog to .specify/extension-catalogs.yml.""" from .extensions import ExtensionCatalog, ValidationError - project_root = Path.cwd() - + project_root = _require_specify_project() specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) # Validate URL tmp_catalog = ExtensionCatalog(project_root) @@ -3801,7 +4125,8 @@ def catalog_add( try: config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} except Exception as e: - console.print(f"[red]Error:[/red] Failed to read {config_path}: {e}") + config_label = _display_project_path(project_root, config_path) + console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}") raise typer.Exit(1) else: config = {} @@ -3833,7 +4158,7 @@ def catalog_add( console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})") console.print(f" URL: {url}") console.print(f" Priority: {priority}") - console.print(f"\nConfig saved to {config_path.relative_to(project_root)}") + console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}") @catalog_app.command("remove") @@ -3841,13 +4166,8 @@ def catalog_remove( name: str = typer.Argument(help="Catalog name to remove"), ): """Remove a catalog from .specify/extension-catalogs.yml.""" - project_root = Path.cwd() - + project_root = _require_specify_project() specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) config_path = specify_dir / "extension-catalogs.yml" if not config_path.exists(): @@ -3889,15 +4209,7 @@ def extension_add( """Install an extension.""" from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() # Validate priority if priority < 1: console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") @@ -4071,15 +4383,7 @@ def extension_remove( """Uninstall an extension.""" from .extensions import ExtensionManager - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() manager = ExtensionManager(project_root) # Resolve extension ID from argument (handles ambiguous names) @@ -4147,15 +4451,7 @@ def extension_search( """Search for available extensions in catalog.""" from .extensions import ExtensionCatalog, ExtensionError - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() catalog = ExtensionCatalog(project_root) try: @@ -4231,15 +4527,7 @@ def extension_info( """Show detailed information about an extension.""" from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() catalog = ExtensionCatalog(project_root) manager = ExtensionManager(project_root) installed = manager.list_installed() @@ -4433,15 +4721,7 @@ def extension_update( from packaging import version as pkg_version import shutil - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() manager = ExtensionManager(project_root) catalog = ExtensionCatalog(project_root) speckit_version = get_speckit_version() @@ -4829,15 +5109,7 @@ def extension_enable( """Enable a disabled extension.""" from .extensions import ExtensionManager, HookExecutor - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() manager = ExtensionManager(project_root) hook_executor = HookExecutor(project_root) @@ -4876,15 +5148,7 @@ def extension_disable( """Disable an extension without removing it.""" from .extensions import ExtensionManager, HookExecutor - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() manager = ExtensionManager(project_root) hook_executor = HookExecutor(project_root) @@ -4926,15 +5190,7 @@ def extension_set_priority( """Set the resolution priority of an installed extension.""" from .extensions import ExtensionManager - project_root = Path.cwd() - - # Check if we're in a spec-kit project - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - console.print("Run this command from a spec-kit project root") - raise typer.Exit(1) - + project_root = _require_specify_project() # Validate priority if priority < 1: console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") @@ -4996,10 +5252,7 @@ def workflow_run( """Run a workflow from an installed ID or local YAML path.""" from .workflows.engine import WorkflowEngine - project_root = Path.cwd() - if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - raise typer.Exit(1) + project_root = _require_specify_project() engine = WorkflowEngine(project_root) engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") @@ -5063,10 +5316,7 @@ def workflow_resume( """Resume a paused or failed workflow run.""" from .workflows.engine import WorkflowEngine - project_root = Path.cwd() - if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - raise typer.Exit(1) + project_root = _require_specify_project() engine = WorkflowEngine(project_root) engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026") @@ -5099,10 +5349,7 @@ def workflow_status( """Show workflow run status.""" from .workflows.engine import WorkflowEngine - project_root = Path.cwd() - if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - raise typer.Exit(1) + project_root = _require_specify_project() engine = WorkflowEngine(project_root) if run_id: @@ -5161,12 +5408,7 @@ def workflow_list(): """List installed workflows.""" from .workflows.catalog import WorkflowRegistry - project_root = Path.cwd() - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - raise typer.Exit(1) - + project_root = _require_specify_project() registry = WorkflowRegistry(project_root) installed = registry.list() @@ -5193,12 +5435,7 @@ def workflow_add( from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError from .workflows.engine import WorkflowDefinition - project_root = Path.cwd() - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - raise typer.Exit(1) - + project_root = _require_specify_project() registry = WorkflowRegistry(project_root) workflows_dir = project_root / ".specify" / "workflows" @@ -5429,12 +5666,7 @@ def workflow_remove( """Uninstall a workflow.""" from .workflows.catalog import WorkflowRegistry - project_root = Path.cwd() - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - raise typer.Exit(1) - + project_root = _require_specify_project() registry = WorkflowRegistry(project_root) if not registry.is_installed(workflow_id): @@ -5459,10 +5691,7 @@ def workflow_search( """Search workflow catalogs.""" from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError - project_root = Path.cwd() - if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - raise typer.Exit(1) + project_root = _require_specify_project() catalog = WorkflowCatalog(project_root) try: @@ -5495,10 +5724,7 @@ def workflow_info( from .workflows.catalog import WorkflowCatalog, WorkflowRegistry, WorkflowCatalogError from .workflows.engine import WorkflowEngine - project_root = Path.cwd() - if not (project_root / ".specify").exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - raise typer.Exit(1) + project_root = _require_specify_project() # Check installed first registry = WorkflowRegistry(project_root) @@ -5592,12 +5818,7 @@ def workflow_catalog_add( """Add a workflow catalog source.""" from .workflows.catalog import WorkflowCatalog, WorkflowValidationError - project_root = Path.cwd() - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - raise typer.Exit(1) - + project_root = _require_specify_project() catalog = WorkflowCatalog(project_root) try: catalog.add_catalog(url, name) @@ -5615,12 +5836,7 @@ def workflow_catalog_remove( """Remove a workflow catalog source by index.""" from .workflows.catalog import WorkflowCatalog, WorkflowValidationError - project_root = Path.cwd() - specify_dir = project_root / ".specify" - if not specify_dir.exists(): - console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)") - raise typer.Exit(1) - + project_root = _require_specify_project() catalog = WorkflowCatalog(project_root) try: removed_name = catalog.remove_catalog(index) diff --git a/src/specify_cli/integration_runtime.py b/src/specify_cli/integration_runtime.py new file mode 100644 index 0000000000..a36dcc672c --- /dev/null +++ b/src/specify_cli/integration_runtime.py @@ -0,0 +1,90 @@ +"""Runtime helpers for integration commands.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from .integration_state import integration_setting, integration_settings + + +ParseOptions = Callable[[Any, str], dict[str, Any] | None] + + +def resolve_integration_options( + integration: Any, + state: dict[str, Any], + key: str, + raw_options: str | None, + *, + parse_options: ParseOptions, +) -> tuple[str | None, dict[str, Any] | None]: + """Resolve raw and parsed options for an integration operation.""" + if raw_options is not None: + return raw_options, parse_options(integration, raw_options) + + setting = integration_setting(state, key) + stored_raw = setting.get("raw_options") + if not isinstance(stored_raw, str): + stored_raw = None + + stored_parsed = setting.get("parsed_options") + if isinstance(stored_parsed, dict): + return stored_raw, stored_parsed or None + + if stored_raw: + return stored_raw, parse_options(integration, stored_raw) + + return None, None + + +def with_integration_setting( + state: dict[str, Any], + key: str, + integration: Any, + *, + script_type: str | None = None, + raw_options: str | None = None, + parsed_options: dict[str, Any] | None = None, +) -> dict[str, dict[str, Any]]: + """Return integration settings with *key* updated.""" + settings = integration_settings(state) + current = dict(settings.get(key, {})) + + if script_type: + current["script"] = script_type + if raw_options is not None: + current["raw_options"] = raw_options + elif "raw_options" in current and not current.get("raw_options"): + current.pop("raw_options", None) + + if parsed_options is not None: + current["parsed_options"] = parsed_options + elif raw_options is not None: + current.pop("parsed_options", None) + + current["invoke_separator"] = integration.effective_invoke_separator(parsed_options) + settings[key] = current + return settings + + +def invoke_separator_for_integration( + integration: Any, + state: dict[str, Any], + key: str, + parsed_options: dict[str, Any] | None = None, +) -> str: + """Resolve the invocation separator for stored/default integration state.""" + if parsed_options is not None: + return integration.effective_invoke_separator(parsed_options) + + setting = integration_setting(state, key) + stored_separator = setting.get("invoke_separator") + if isinstance(stored_separator, str) and stored_separator: + return stored_separator + + stored_parsed = setting.get("parsed_options") + if isinstance(stored_parsed, dict): + return integration.effective_invoke_separator(stored_parsed) + + return integration.effective_invoke_separator(None) diff --git a/src/specify_cli/integration_state.py b/src/specify_cli/integration_state.py new file mode 100644 index 0000000000..ac892dfbf6 --- /dev/null +++ b/src/specify_cli/integration_state.py @@ -0,0 +1,161 @@ +"""State helpers for installed AI agent integrations.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +INTEGRATION_JSON = ".specify/integration.json" +INTEGRATION_STATE_SCHEMA = 1 + + +def clean_integration_key(key: Any) -> str | None: + """Return a stripped integration key, or None for empty/non-string values.""" + if not isinstance(key, str) or not key.strip(): + return None + return key.strip() + + +def dedupe_integration_keys(keys: list[Any]) -> list[str]: + """Return a de-duplicated list of non-empty integration keys.""" + seen: set[str] = set() + deduped: list[str] = [] + for key in keys: + clean = clean_integration_key(key) + if clean is None: + continue + if clean in seen: + continue + seen.add(clean) + deduped.append(clean) + return deduped + + +def normalize_integration_settings(settings: Any) -> dict[str, dict[str, Any]]: + """Return JSON-safe per-integration runtime settings.""" + if not isinstance(settings, dict): + return {} + + normalized: dict[str, dict[str, Any]] = {} + for key, value in settings.items(): + if not isinstance(key, str) or not key.strip() or not isinstance(value, dict): + continue + + clean: dict[str, Any] = {} + script = value.get("script") + if isinstance(script, str) and script.strip(): + clean["script"] = script.strip() + + raw_options = value.get("raw_options") + if isinstance(raw_options, str): + clean["raw_options"] = raw_options + + parsed_options = value.get("parsed_options") + if isinstance(parsed_options, dict): + clean["parsed_options"] = parsed_options + + invoke_separator = value.get("invoke_separator") + if isinstance(invoke_separator, str) and invoke_separator.strip(): + clean["invoke_separator"] = invoke_separator.strip() + + if clean: + normalized[key.strip()] = clean + + return normalized + + +def _normalized_integration_state_schema(value: Any) -> int: + if isinstance(value, int) and not isinstance(value, bool) and value > INTEGRATION_STATE_SCHEMA: + return value + return INTEGRATION_STATE_SCHEMA + + +def normalize_integration_state(data: dict[str, Any]) -> dict[str, Any]: + """Normalize legacy and multi-install integration metadata.""" + legacy_key = clean_integration_key(data.get("integration")) + default_key = clean_integration_key(data.get("default_integration")) or legacy_key + + installed = data.get("installed_integrations") + installed_keys = dedupe_integration_keys(installed if isinstance(installed, list) else []) + if not default_key and installed_keys: + default_key = installed_keys[0] + if default_key and default_key not in installed_keys: + installed_keys.insert(0, default_key) + + settings = normalize_integration_settings(data.get("integration_settings")) + + normalized = dict(data) + normalized["integration_state_schema"] = _normalized_integration_state_schema( + data.get("integration_state_schema") + ) + if default_key: + normalized["integration"] = default_key + normalized["default_integration"] = default_key + else: + normalized.pop("integration", None) + normalized.pop("default_integration", None) + normalized["installed_integrations"] = installed_keys + normalized["integration_settings"] = { + key: settings[key] for key in installed_keys if key in settings + } + return normalized + + +def default_integration_key(state: dict[str, Any]) -> str | None: + """Return the default integration key from normalized state.""" + key = state.get("default_integration") or state.get("integration") + return clean_integration_key(key) + + +def installed_integration_keys(state: dict[str, Any]) -> list[str]: + """Return installed integration keys from normalized state.""" + return dedupe_integration_keys(state.get("installed_integrations", [])) + + +def integration_settings(state: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Return normalized per-integration settings from state.""" + return normalize_integration_settings(state.get("integration_settings")) + + +def integration_setting(state: dict[str, Any], key: str) -> dict[str, Any]: + """Return stored runtime settings for *key*.""" + return dict(integration_settings(state).get(key, {})) + + +def write_integration_json( + project_root: Path, + *, + version: str, + integration_key: str | None, + installed_integrations: list[str] | None = None, + settings: dict[str, dict[str, Any]] | None = None, +) -> None: + """Write ``.specify/integration.json`` with legacy-compatible state.""" + dest = project_root / INTEGRATION_JSON + dest.parent.mkdir(parents=True, exist_ok=True) + + integration_key = clean_integration_key(integration_key) + installed = dedupe_integration_keys(installed_integrations or []) + if integration_key and integration_key not in installed: + installed.insert(0, integration_key) + if not integration_key and installed: + integration_key = installed[0] + + normalized_settings = normalize_integration_settings(settings or {}) + normalized_settings = { + key: normalized_settings[key] for key in installed if key in normalized_settings + } + + data: dict[str, Any] = { + "version": version, + "integration_state_schema": INTEGRATION_STATE_SCHEMA, + "installed_integrations": installed, + "integration_settings": normalized_settings, + } + if integration_key: + data["integration"] = integration_key + data["default_integration"] = integration_key + + dest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") diff --git a/src/specify_cli/integrations/auggie/__init__.py b/src/specify_cli/integrations/auggie/__init__.py index 9715e936ef..08e20fbc25 100644 --- a/src/specify_cli/integrations/auggie/__init__.py +++ b/src/specify_cli/integrations/auggie/__init__.py @@ -19,3 +19,4 @@ class AuggieIntegration(MarkdownIntegration): "extension": ".md", } context_file = ".augment/rules/specify-rules.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index f3b74b0c05..c46340ddff 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -87,6 +87,14 @@ class IntegrationBase(ABC): invoke_separator: str = "." """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" + multi_install_safe: bool = False + """Whether this integration is declared safe to install alongside others. + + Safe integrations must use a static, unique agent root, command directory, + and context file. Registry tests enforce those invariants for every + integration that sets this flag. + """ + # -- Markers for managed context section ------------------------------ CONTEXT_MARKER_START = "" diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 3e39db717e..88aef85285 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -53,6 +53,7 @@ class ClaudeIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = "CLAUDE.md" + multi_install_safe = True @staticmethod def inject_argument_hint(content: str, hint: str) -> str: diff --git a/src/specify_cli/integrations/codebuddy/__init__.py b/src/specify_cli/integrations/codebuddy/__init__.py index 061ac7641f..980ac7fed7 100644 --- a/src/specify_cli/integrations/codebuddy/__init__.py +++ b/src/specify_cli/integrations/codebuddy/__init__.py @@ -19,3 +19,4 @@ class CodebuddyIntegration(MarkdownIntegration): "extension": ".md", } context_file = "CODEBUDDY.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index b3b509b654..1c24a84bd2 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -27,6 +27,7 @@ class CodexIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = "AGENTS.md" + multi_install_safe = True def build_exec_args( self, diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index a5472654fa..70af454ce9 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -26,6 +26,7 @@ class CursorAgentIntegration(SkillsIntegration): } context_file = ".cursor/rules/specify-rules.mdc" + multi_install_safe = True @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py index d66f0b80bc..7c6fe159c7 100644 --- a/src/specify_cli/integrations/gemini/__init__.py +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -19,3 +19,4 @@ class GeminiIntegration(TomlIntegration): "extension": ".toml", } context_file = "GEMINI.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/iflow/__init__.py b/src/specify_cli/integrations/iflow/__init__.py index 4acc2cf372..65d4d21c63 100644 --- a/src/specify_cli/integrations/iflow/__init__.py +++ b/src/specify_cli/integrations/iflow/__init__.py @@ -19,3 +19,4 @@ class IflowIntegration(MarkdownIntegration): "extension": ".md", } context_file = "IFLOW.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 0cc3b3f0ff..98d0494a8a 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -19,3 +19,4 @@ class JunieIntegration(MarkdownIntegration): "extension": ".md", } context_file = ".junie/AGENTS.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py index ffd38f741a..11674dd9f1 100644 --- a/src/specify_cli/integrations/kilocode/__init__.py +++ b/src/specify_cli/integrations/kilocode/__init__.py @@ -19,3 +19,4 @@ class KilocodeIntegration(MarkdownIntegration): "extension": ".md", } context_file = ".kilocode/rules/specify-rules.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 5421d48012..3b257768e2 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -36,6 +36,7 @@ class KimiIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = "KIMI.md" + multi_install_safe = True @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/manifest.py b/src/specify_cli/integrations/manifest.py index 50ac08ea3d..258c536e5b 100644 --- a/src/specify_cli/integrations/manifest.py +++ b/src/specify_cli/integrations/manifest.py @@ -11,6 +11,7 @@ import hashlib import json import os +import tempfile from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -47,6 +48,59 @@ def _validate_rel_path(rel: Path, root: Path) -> Path: return resolved +def _manifest_path_label(root: Path, path: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() + + +def _ensure_safe_manifest_directory(root: Path, directory: Path) -> None: + """Create a manifest directory without following symlinked parents.""" + root_resolved = root.resolve() + try: + rel = directory.relative_to(root) + except ValueError: + label = _manifest_path_label(root, directory) + raise ValueError(f"Integration manifest directory escapes project root: {label}") from None + + current = root + for part in rel.parts: + current = current / part + label = _manifest_path_label(root, current) + if current.is_symlink(): + raise ValueError(f"Refusing to use symlinked integration manifest directory: {label}") + if current.exists(): + if not current.is_dir(): + raise ValueError(f"Integration manifest directory path is not a directory: {label}") + try: + current.resolve().relative_to(root_resolved) + except (OSError, ValueError): + raise ValueError(f"Integration manifest directory escapes project root: {label}") from None + continue + current.mkdir() + try: + current.resolve().relative_to(root_resolved) + except (OSError, ValueError): + raise ValueError(f"Integration manifest directory escapes project root: {label}") from None + + +def _ensure_safe_manifest_destination(root: Path, path: Path) -> None: + """Refuse manifest writes that would escape the project or follow symlinks.""" + root_resolved = root.resolve() + _ensure_safe_manifest_directory(root, path.parent) + label = _manifest_path_label(root, path) + if path.is_symlink(): + raise ValueError(f"Refusing to overwrite symlinked integration manifest path: {label}") + if path.exists(): + if not path.is_file(): + raise ValueError(f"Integration manifest path is not a file: {label}") + try: + path.resolve().relative_to(root_resolved) + except (OSError, ValueError): + raise ValueError(f"Integration manifest path escapes project root: {label}") from None + + class IntegrationManifest: """Tracks files installed by a single integration. @@ -217,8 +271,19 @@ def save(self) -> Path: "files": self._files, } path = self.manifest_path - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + content = json.dumps(data, indent=2) + "\n" + _ensure_safe_manifest_destination(self.project_root, path) + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temp_path = Path(temp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(content) + temp_path.chmod(0o644) + _ensure_safe_manifest_destination(self.project_root, path) + os.replace(temp_path, path) + finally: + if temp_path.exists(): + temp_path.unlink() return path @classmethod diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py index 541001be17..ee2d4b6255 100644 --- a/src/specify_cli/integrations/qodercli/__init__.py +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -19,3 +19,4 @@ class QodercliIntegration(MarkdownIntegration): "extension": ".md", } context_file = "QODER.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py index d9d930152c..2506a57681 100644 --- a/src/specify_cli/integrations/qwen/__init__.py +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -19,3 +19,4 @@ class QwenIntegration(MarkdownIntegration): "extension": ".md", } context_file = "QWEN.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/roo/__init__.py b/src/specify_cli/integrations/roo/__init__.py index 3c680e7e35..f610a3cc63 100644 --- a/src/specify_cli/integrations/roo/__init__.py +++ b/src/specify_cli/integrations/roo/__init__.py @@ -19,3 +19,4 @@ class RooIntegration(MarkdownIntegration): "extension": ".md", } context_file = ".roo/rules/specify-rules.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/shai/__init__.py b/src/specify_cli/integrations/shai/__init__.py index 7a9d1deb02..123953da72 100644 --- a/src/specify_cli/integrations/shai/__init__.py +++ b/src/specify_cli/integrations/shai/__init__.py @@ -19,3 +19,4 @@ class ShaiIntegration(MarkdownIntegration): "extension": ".md", } context_file = "SHAI.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py index 2928a214a7..0d0076bc56 100644 --- a/src/specify_cli/integrations/tabnine/__init__.py +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -19,3 +19,4 @@ class TabnineIntegration(TomlIntegration): "extension": ".toml", } context_file = "TABNINE.md" + multi_install_safe = True diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py index 343a7527f8..4556487d07 100644 --- a/src/specify_cli/integrations/trae/__init__.py +++ b/src/specify_cli/integrations/trae/__init__.py @@ -27,6 +27,7 @@ class TraeIntegration(SkillsIntegration): "extension": "/SKILL.md", } context_file = ".trae/rules/project_rules.md" + multi_install_safe = True @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/windsurf/__init__.py b/src/specify_cli/integrations/windsurf/__init__.py index f0f77d318e..ae5c3301f4 100644 --- a/src/specify_cli/integrations/windsurf/__init__.py +++ b/src/specify_cli/integrations/windsurf/__init__.py @@ -19,3 +19,4 @@ class WindsurfIntegration(MarkdownIntegration): "extension": ".md", } context_file = ".windsurf/rules/specify-rules.md" + multi_install_safe = True diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py new file mode 100644 index 0000000000..1e8be7b282 --- /dev/null +++ b/src/specify_cli/shared_infra.py @@ -0,0 +1,317 @@ +"""Shared Spec Kit infrastructure installation helpers.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from typing import Any + +from .integrations.base import IntegrationBase +from .integrations.manifest import IntegrationManifest + + +def load_speckit_manifest( + project_path: Path, + *, + version: str, + console: Any | None = None, +) -> IntegrationManifest: + """Load the shared infrastructure manifest, preserving existing entries.""" + manifest_path = project_path / ".specify" / "integrations" / "speckit.manifest.json" + if manifest_path.exists(): + try: + manifest = IntegrationManifest.load("speckit", project_path) + manifest.version = version + return manifest + except (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) as exc: + if console is not None: + console.print( + f"[yellow]Warning:[/yellow] Could not read shared infrastructure " + f"manifest at {manifest_path}: {exc}" + ) + console.print( + "A new shared manifest will be created; previously tracked " + "shared files may be treated as untracked." + ) + return IntegrationManifest("speckit", project_path, version=version) + + +def shared_templates_source( + *, + core_pack: Path | None, + repo_root: Path, +) -> Path: + """Return the bundled/source shared templates directory.""" + if core_pack and (core_pack / "templates").is_dir(): + return core_pack / "templates" + return repo_root / "templates" + + +def shared_scripts_source( + *, + core_pack: Path | None, + repo_root: Path, +) -> Path: + """Return the bundled/source shared scripts directory.""" + if core_pack and (core_pack / "scripts").is_dir(): + return core_pack / "scripts" + return repo_root / "scripts" + + +def _shared_destination_label(project_path: Path, dest: Path) -> str: + try: + return dest.relative_to(project_path).as_posix() + except ValueError: + return str(dest) + + +def _shared_relative_path(project_path: Path, dest: Path) -> Path: + try: + rel = dest.relative_to(project_path) + except ValueError: + label = _shared_destination_label(project_path, dest) + raise ValueError(f"Shared infrastructure path escapes project root: {label}") from None + + if rel.is_absolute() or ".." in rel.parts: + label = _shared_destination_label(project_path, dest) + raise ValueError(f"Shared infrastructure path escapes project root: {label}") + return rel + + +def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create: bool = True) -> None: + """Create a shared infra directory without following symlinked parents.""" + root = project_path.resolve() + rel = _shared_relative_path(project_path, directory) + current = project_path + + for part in rel.parts: + current = current / part + label = _shared_destination_label(project_path, current) + if current.is_symlink(): + raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}") + if current.exists(): + if not current.is_dir(): + raise ValueError(f"Shared infrastructure directory path is not a directory: {label}") + try: + current.resolve().relative_to(root) + except (OSError, ValueError): + raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None + continue + if not create: + raise ValueError(f"Shared infrastructure directory does not exist: {label}") + current.mkdir() + if current.is_symlink(): + raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}") + try: + current.resolve().relative_to(root) + except (OSError, ValueError): + raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None + + +def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None: + """Validate existing directory parents while allowing missing directories.""" + root = project_path.resolve() + rel = _shared_relative_path(project_path, directory) + current = project_path + + for part in rel.parts: + current = current / part + label = _shared_destination_label(project_path, current) + if current.is_symlink(): + raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}") + if not current.exists(): + continue + if not current.is_dir(): + raise ValueError(f"Shared infrastructure directory path is not a directory: {label}") + try: + current.resolve().relative_to(root) + except (OSError, ValueError): + raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None + + +def _ensure_safe_shared_destination( + project_path: Path, + dest: Path, + *, + parent_must_exist: bool = True, +) -> None: + """Refuse shared infra writes that would escape or follow symlinks.""" + root = project_path.resolve() + _shared_relative_path(project_path, dest) + if parent_must_exist: + _ensure_safe_shared_directory(project_path, dest.parent, create=False) + else: + _validate_safe_shared_directory(project_path, dest.parent) + label = _shared_destination_label(project_path, dest) + if dest.is_symlink(): + raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}") + + if dest.exists(): + try: + dest.resolve().relative_to(root) + except (OSError, ValueError): + raise ValueError(f"Shared infrastructure destination escapes project root: {label}") from None + + +def _write_shared_text(project_path: Path, dest: Path, content: str) -> None: + _write_shared_bytes(project_path, dest, content.encode("utf-8")) + + +def _write_shared_bytes( + project_path: Path, + dest: Path, + content: bytes, + *, + mode: int = 0o644, +) -> None: + _ensure_safe_shared_destination(project_path, dest) + fd, temp_name = tempfile.mkstemp(prefix=f".{dest.name}.", dir=dest.parent) + temp_path = Path(temp_name) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(content) + temp_path.chmod(mode) + _ensure_safe_shared_destination(project_path, dest) + os.replace(temp_path, dest) + finally: + if temp_path.exists(): + temp_path.unlink() + + +def refresh_shared_templates( + project_path: Path, + *, + version: str, + core_pack: Path | None, + repo_root: Path, + console: Any, + invoke_separator: str, + force: bool = False, +) -> None: + """Refresh default-sensitive shared templates without touching scripts.""" + templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root) + if not templates_src.is_dir(): + return + + manifest = load_speckit_manifest(project_path, version=version, console=console) + tracked_files = manifest.files + modified = set(manifest.check_modified()) + skipped_files: list[str] = [] + planned_updates: list[tuple[Path, str, str]] = [] + + dest_templates = project_path / ".specify" / "templates" + _ensure_safe_shared_directory(project_path, dest_templates) + for src in templates_src.iterdir(): + if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."): + continue + + dst = dest_templates / src.name + _ensure_safe_shared_destination(project_path, dst) + rel = dst.relative_to(project_path).as_posix() + if dst.exists() and not force: + if rel not in tracked_files or rel in modified: + skipped_files.append(rel) + continue + + content = src.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs(content, invoke_separator) + planned_updates.append((dst, rel, content)) + + for dst, rel, content in planned_updates: + _write_shared_text(project_path, dst, content) + manifest.record_existing(rel) + + manifest.save() + + if skipped_files: + console.print( + f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:" + ) + for rel in skipped_files: + console.print(f" {rel}") + + +def install_shared_infra( + project_path: Path, + script_type: str, + *, + version: str, + core_pack: Path | None, + repo_root: Path, + console: Any, + force: bool = False, + invoke_separator: str = ".", +) -> bool: + """Install shared scripts and templates into *project_path*.""" + manifest = load_speckit_manifest(project_path, version=version, console=console) + skipped_files: list[str] = [] + planned_copies: list[tuple[Path, str, bytes, int]] = [] + planned_templates: list[tuple[Path, str, str]] = [] + + scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root) + if scripts_src.is_dir(): + dest_scripts = project_path / ".specify" / "scripts" + _ensure_safe_shared_directory(project_path, dest_scripts) + variant_dir = "bash" if script_type == "sh" else "powershell" + variant_src = scripts_src / variant_dir + if variant_src.is_dir(): + dest_variant = dest_scripts / variant_dir + _ensure_safe_shared_directory(project_path, dest_variant) + for src_path in variant_src.rglob("*"): + if not src_path.is_file(): + continue + + rel_path = src_path.relative_to(variant_src) + dst_path = dest_variant / rel_path + _ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False) + if dst_path.exists() and not force: + skipped_files.append(dst_path.relative_to(project_path).as_posix()) + continue + + _ensure_safe_shared_directory(project_path, dst_path.parent) + rel = dst_path.relative_to(project_path).as_posix() + planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777)) + + templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root) + if templates_src.is_dir(): + dest_templates = project_path / ".specify" / "templates" + _ensure_safe_shared_directory(project_path, dest_templates) + for src in templates_src.iterdir(): + if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."): + continue + + dst = dest_templates / src.name + _ensure_safe_shared_destination(project_path, dst) + if dst.exists() and not force: + skipped_files.append(dst.relative_to(project_path).as_posix()) + continue + + content = src.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs(content, invoke_separator) + rel = dst.relative_to(project_path).as_posix() + planned_templates.append((dst, rel, content)) + + for dst_path, rel, content, mode in planned_copies: + _ensure_safe_shared_directory(project_path, dst_path.parent) + _write_shared_bytes(project_path, dst_path, content, mode=mode) + manifest.record_existing(rel) + + for dst, rel, content in planned_templates: + _write_shared_text(project_path, dst, content) + manifest.record_existing(rel) + + if skipped_files: + console.print( + f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:" + ) + for path in skipped_files: + console.print(f" {path}") + console.print( + "To refresh shared infrastructure, run " + "[cyan]specify init --here --force[/cyan] or " + "[cyan]specify integration upgrade --force[/cyan]." + ) + + manifest.save() + return True diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 95dcf206e8..bb17c86bc1 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -1,13 +1,21 @@ """Tests for --integration flag on specify init (CLI-level).""" +import io import json import os +import pytest import yaml +from rich.console import Console from tests.conftest import strip_ansi +class _NoopConsole: + def print(self, *args, **kwargs): + pass + + def _normalize_cli_output(output: str) -> str: output = strip_ansi(output) output = " ".join(output.split()) @@ -254,6 +262,310 @@ def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys): normalized = " ".join(captured.out.split()) assert "specify integration upgrade --force" in normalized + def test_shared_infra_warns_when_manifest_cannot_be_loaded(self, tmp_path, capsys): + """Invalid shared manifests warn before falling back to a new manifest.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "bad-shared-manifest-test" + project.mkdir() + integrations_dir = project / ".specify" / "integrations" + integrations_dir.mkdir(parents=True) + manifest_path = integrations_dir / "speckit.manifest.json" + manifest_path.write_text("{not json", encoding="utf-8") + + _install_shared_infra(project, "sh") + + captured = capsys.readouterr() + assert "Could not read shared infrastructure manifest" in captured.out + assert "A new shared manifest will be created" in captured.out + + def test_shared_infra_warns_when_manifest_cannot_be_decoded(self, tmp_path, capsys): + """Non-UTF-8 shared manifests warn before falling back to a new manifest.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "bad-shared-manifest-encoding-test" + project.mkdir() + integrations_dir = project / ".specify" / "integrations" + integrations_dir.mkdir(parents=True) + manifest_path = integrations_dir / "speckit.manifest.json" + manifest_path.write_bytes(b"\xff\xfe\x00") + + _install_shared_infra(project, "sh") + + captured = capsys.readouterr() + assert "Could not read shared infrastructure manifest" in captured.out + assert "A new shared manifest will be created" in captured.out + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path): + """Shared script refreshes must not follow destination symlinks.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "symlink-script-test" + project.mkdir() + (project / ".specify").mkdir() + + outside = tmp_path / "outside-script.sh" + outside.write_text("# outside\n", encoding="utf-8") + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + os.symlink(outside, scripts_dir / "common.sh") + + with pytest.raises(ValueError, match="Refusing to overwrite symlinked"): + _install_shared_infra(project, "sh", force=True) + + assert outside.read_text(encoding="utf-8") == "# outside\n" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path): + """Shared template installs must not follow destination symlinks.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "symlink-template-test" + project.mkdir() + (project / ".specify").mkdir() + + outside = tmp_path / "outside-template.md" + outside.write_text("# outside\n", encoding="utf-8") + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + os.symlink(outside, templates_dir / "plan-template.md") + + with pytest.raises(ValueError, match="Refusing to overwrite symlinked"): + _install_shared_infra(project, "sh", force=True) + + assert outside.read_text(encoding="utf-8") == "# outside\n" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_template_refresh_refuses_symlinked_destination(self, tmp_path): + """Template-only refreshes must not follow destination symlinks.""" + from specify_cli import _refresh_shared_templates + + project = tmp_path / "symlink-refresh-test" + project.mkdir() + (project / ".specify").mkdir() + + outside = tmp_path / "outside-refresh.md" + outside.write_text("# outside\n", encoding="utf-8") + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + os.symlink(outside, templates_dir / "plan-template.md") + + with pytest.raises(ValueError, match="Refusing to overwrite symlinked"): + _refresh_shared_templates(project, invoke_separator=".", force=True) + + assert outside.read_text(encoding="utf-8") == "# outside\n" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path): + """Shared infra directory creation must not follow a symlinked .specify.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "symlink-dir-test" + project.mkdir() + outside = tmp_path / "outside-specify" + outside.mkdir() + os.symlink(outside, project / ".specify") + + with pytest.raises(ValueError, match="symlinked shared infrastructure directory"): + _install_shared_infra(project, "sh", force=True) + + assert not (outside / "scripts").exists() + assert not (outside / "templates").exists() + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_infra_refuses_symlinked_shared_manifest(self, tmp_path): + """Shared infra manifest saves must not follow destination symlinks.""" + from specify_cli.shared_infra import install_shared_infra + + project = tmp_path / "symlink-shared-manifest-test" + project.mkdir() + integrations_dir = project / ".specify" / "integrations" + integrations_dir.mkdir(parents=True) + + outside = tmp_path / "outside-manifest.json" + outside.write_text("# outside\n", encoding="utf-8") + os.symlink(outside, integrations_dir / "speckit.manifest.json") + + core_pack = tmp_path / "core-pack" + templates_src = core_pack / "templates" + templates_src.mkdir(parents=True) + (templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8") + + with pytest.raises(ValueError, match="symlinked integration manifest"): + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + force=True, + ) + + assert outside.read_text(encoding="utf-8") == "# outside\n" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_template_refresh_preflights_before_writing(self, tmp_path): + """Template refresh validates all destinations before writing any file.""" + from specify_cli.shared_infra import refresh_shared_templates + + project = tmp_path / "preflight-refresh-test" + project.mkdir() + templates_dir = project / ".specify" / "templates" + templates_dir.mkdir(parents=True) + + core_pack = tmp_path / "core-pack" + templates_src = core_pack / "templates" + templates_src.mkdir(parents=True) + (templates_src / "a-template.md").write_text("# new a\n", encoding="utf-8") + (templates_src / "z-template.md").write_text("# new z\n", encoding="utf-8") + + existing = templates_dir / "a-template.md" + existing.write_text("# old a\n", encoding="utf-8") + outside = tmp_path / "outside-z.md" + outside.write_text("# outside\n", encoding="utf-8") + os.symlink(outside, templates_dir / "z-template.md") + + with pytest.raises(ValueError, match="Refusing to overwrite symlinked"): + refresh_shared_templates( + project, + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + invoke_separator=".", + force=True, + ) + + assert existing.read_text(encoding="utf-8") == "# old a\n" + assert outside.read_text(encoding="utf-8") == "# outside\n" + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_shared_infra_install_preflights_before_writing(self, tmp_path): + """Full shared infra installs validate destinations before writing any file.""" + from specify_cli.shared_infra import install_shared_infra + + project = tmp_path / "preflight-install-test" + project.mkdir() + scripts_dir = project / ".specify" / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + + core_pack = tmp_path / "core-pack" + scripts_src = core_pack / "scripts" / "bash" + scripts_src.mkdir(parents=True) + (scripts_src / "a.sh").write_text("# new a\n", encoding="utf-8") + (scripts_src / "z.sh").write_text("# new z\n", encoding="utf-8") + + existing = scripts_dir / "a.sh" + existing.write_text("# old a\n", encoding="utf-8") + outside = tmp_path / "outside-z.sh" + outside.write_text("# outside\n", encoding="utf-8") + os.symlink(outside, scripts_dir / "z.sh") + + with pytest.raises(ValueError, match="Refusing to overwrite symlinked"): + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + force=True, + ) + + assert existing.read_text(encoding="utf-8") == "# old a\n" + assert outside.read_text(encoding="utf-8") == "# outside\n" + + def test_shared_infra_install_supports_nested_script_sources(self, tmp_path): + """Nested script source files create safe destination parents at write time.""" + from specify_cli.shared_infra import install_shared_infra + + project = tmp_path / "nested-script-install-test" + project.mkdir() + + core_pack = tmp_path / "core-pack" + nested_src = core_pack / "scripts" / "bash" / "nested" + nested_src.mkdir(parents=True) + (nested_src / "deep.sh").write_text("# nested\n", encoding="utf-8") + + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + force=True, + ) + + nested_dest = project / ".specify" / "scripts" / "bash" / "nested" / "deep.sh" + assert nested_dest.read_text(encoding="utf-8") == "# nested\n" + + def test_shared_infra_skip_warning_uses_posix_paths(self, tmp_path): + """Skipped shared infra paths are reported consistently across platforms.""" + from specify_cli.shared_infra import install_shared_infra + + project = tmp_path / "posix-skip-warning-test" + project.mkdir() + nested_dest = project / ".specify" / "scripts" / "bash" / "nested" + nested_dest.mkdir(parents=True) + (nested_dest / "deep.sh").write_text("# existing script\n", encoding="utf-8") + + templates_dest = project / ".specify" / "templates" + templates_dest.mkdir(parents=True) + (templates_dest / "plan-template.md").write_text("# existing template\n", encoding="utf-8") + + core_pack = tmp_path / "core-pack" + nested_src = core_pack / "scripts" / "bash" / "nested" + nested_src.mkdir(parents=True) + (nested_src / "deep.sh").write_text("# bundled script\n", encoding="utf-8") + + templates_src = core_pack / "templates" + templates_src.mkdir(parents=True) + (templates_src / "plan-template.md").write_text("# bundled template\n", encoding="utf-8") + + buffer = io.StringIO() + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=Console(file=buffer, force_terminal=False, width=120), + force=False, + ) + + output = buffer.getvalue() + assert ".specify/scripts/bash/nested/deep.sh" in output + assert ".specify/templates/plan-template.md" in output + + @pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not stable on Windows") + def test_shared_template_writes_are_not_world_writable(self, tmp_path): + """Shared template writes use a safe default mode instead of chmod 666.""" + from specify_cli.shared_infra import install_shared_infra + + project = tmp_path / "template-mode-test" + project.mkdir() + + core_pack = tmp_path / "core-pack" + templates_src = core_pack / "templates" + templates_src.mkdir(parents=True) + (templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8") + + install_shared_infra( + project, + "sh", + version="test", + core_pack=core_pack, + repo_root=tmp_path / "unused", + console=_NoopConsole(), + force=True, + ) + + written = project / ".specify" / "templates" / "plan-template.md" + assert written.stat().st_mode & 0o777 == 0o644 + def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys): """No skip warning when force=True (all files overwritten).""" from specify_cli import _install_shared_infra @@ -712,6 +1024,7 @@ def test_primary_integration_commands_require_specify_project(self, tmp_path): commands = [ ["integration", "list"], ["integration", "install", "codex"], + ["integration", "use", "codex"], ["integration", "uninstall"], ["integration", "switch", "codex"], ["integration", "upgrade"], @@ -730,10 +1043,92 @@ def test_integration_commands_require_specify_directory(self, tmp_path): project.mkdir() (project / ".specify").write_text("not a directory") - result = self._invoke(["integration", "list"], project) + commands = [ + ["integration", "list"], + ["integration", "use", "codex"], + ] - assert result.exit_code == 1, result.output - assert "Not a spec-kit project" in result.output + for command in commands: + result = self._invoke(command, project) + assert result.exit_code == 1, result.output + assert "Not a spec-kit project" in result.output + + def test_project_scoped_commands_require_specify_directory(self, tmp_path): + project = tmp_path / "bad-feature-commands" + project.mkdir() + (project / ".specify").write_text("not a directory") + + commands = [ + ["preset", "list"], + ["preset", "add", "demo"], + ["preset", "remove", "demo"], + ["preset", "search"], + ["preset", "resolve", "spec-template"], + ["preset", "info", "demo"], + ["preset", "set-priority", "demo", "5"], + ["preset", "enable", "demo"], + ["preset", "disable", "demo"], + ["preset", "catalog", "list"], + ["preset", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"], + ["preset", "catalog", "remove", "demo"], + ["extension", "list"], + ["extension", "add", "demo"], + ["extension", "remove", "demo"], + ["extension", "search"], + ["extension", "info", "demo"], + ["extension", "update", "demo"], + ["extension", "enable", "demo"], + ["extension", "disable", "demo"], + ["extension", "set-priority", "demo", "5"], + ["extension", "catalog", "list"], + ["extension", "catalog", "add", "https://example.com/catalog.yml", "--name", "demo"], + ["extension", "catalog", "remove", "demo"], + ["workflow", "run", "demo"], + ["workflow", "resume", "demo"], + ["workflow", "status"], + ["workflow", "list"], + ["workflow", "add", "demo"], + ["workflow", "remove", "demo"], + ["workflow", "search"], + ["workflow", "info", "demo"], + ["workflow", "catalog", "add", "https://example.com/catalog.yml"], + ["workflow", "catalog", "remove", "0"], + ] + + for command in commands: + result = self._invoke(command, project) + failure_context = ( + f"command={command!r}, exit_code={result.exit_code}, output={result.output!r}" + ) + assert result.exit_code == 1, failure_context + assert "Not a spec-kit project" in result.output, failure_context + + def test_catalog_config_output_uses_posix_paths(self, tmp_path): + project = self._make_project(tmp_path) + + preset_add = self._invoke([ + "preset", "catalog", "add", + "https://example.com/preset-catalog.yml", + "--name", "demo-presets", + ], project) + assert preset_add.exit_code == 0, preset_add.output + assert "Config saved to .specify/preset-catalogs.yml" in preset_add.output + + preset_list = self._invoke(["preset", "catalog", "list"], project) + assert preset_list.exit_code == 0, preset_list.output + assert "Config: .specify/preset-catalogs.yml" in preset_list.output + + extension_add = self._invoke([ + "extension", "catalog", "add", + "https://example.com/extension-catalog.yml", + "--name", "demo-extensions", + ], project) + assert extension_add.exit_code == 0, extension_add.output + assert "Config saved to .specify/extension-catalogs.yml" in extension_add.output + + extension_list = self._invoke(["extension", "catalog", "list"], project) + assert extension_list.exit_code == 0, extension_list.output + assert "Config: .specify/extension-catalogs.yml" in extension_list.output # -- search ------------------------------------------------------------ diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 6c55ae4ebc..8b21ddfb8b 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -670,7 +670,7 @@ def test_upgrade_wrong_integration_key(self, tmp_path): finally: os.chdir(old) assert result.exit_code != 0 - assert "not the currently installed integration" in result.output + assert "not installed" in result.output def test_upgrade_no_manifest(self, tmp_path): """Upgrade with missing manifest suggests fresh install.""" diff --git a/tests/integrations/test_integration_state.py b/tests/integrations/test_integration_state.py new file mode 100644 index 0000000000..1d6bdb0268 --- /dev/null +++ b/tests/integrations/test_integration_state.py @@ -0,0 +1,86 @@ +"""Tests for integration state normalization helpers.""" + +import json + +from specify_cli.integration_state import ( + INTEGRATION_JSON, + default_integration_key, + integration_setting, + normalize_integration_state, + write_integration_json, +) + + +def test_normalize_integration_state_strips_default_key_without_duplicates(): + state = normalize_integration_state( + { + "default_integration": " claude ", + "integration": " claude ", + "installed_integrations": ["claude"], + } + ) + + assert state["integration"] == "claude" + assert state["default_integration"] == "claude" + assert state["installed_integrations"] == ["claude"] + + +def test_normalize_integration_state_strips_legacy_key_fallback(): + state = normalize_integration_state( + { + "integration": " codex ", + "installed_integrations": [], + } + ) + + assert state["integration"] == "codex" + assert state["default_integration"] == "codex" + assert state["installed_integrations"] == ["codex"] + + +def test_normalize_integration_state_preserves_newer_schema(): + state = normalize_integration_state( + { + "integration_state_schema": 99, + "integration": "claude", + "installed_integrations": ["claude"], + "future_field": {"keep": True}, + } + ) + + assert state["integration_state_schema"] == 99 + assert state["future_field"] == {"keep": True} + + +def test_default_integration_key_strips_raw_state_values(): + assert default_integration_key({"default_integration": " claude "}) == "claude" + assert default_integration_key({"integration": " codex "}) == "codex" + + +def test_integration_settings_strip_invoke_separator(): + setting = integration_setting( + { + "integration_settings": { + "claude": { + "invoke_separator": " - ", + } + } + }, + "claude", + ) + + assert setting["invoke_separator"] == "-" + + +def test_write_integration_json_strips_integration_key(tmp_path): + write_integration_json( + tmp_path, + version="1.2.3", + integration_key=" claude ", + installed_integrations=["claude"], + ) + + state = json.loads((tmp_path / INTEGRATION_JSON).read_text(encoding="utf-8")) + assert state["integration"] == "claude" + assert state["default_integration"] == "claude" + assert state["installed_integrations"] == ["claude"] diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 3952557cf2..750bbb6efa 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -3,6 +3,7 @@ import json import os +import pytest from typer.testing import CliRunner from specify_cli import app @@ -41,6 +42,17 @@ def _run_in_project(project, args): os.chdir(old_cwd) +def _write_invalid_manifest(project, key): + manifest = project / ".specify" / "integrations" / f"{key}.manifest.json" + manifest.write_bytes(b"\xff\xfe\x00") + return manifest + + +def _integration_list_row_cells(output: str, key: str) -> list[str]: + row = next(line for line in output.splitlines() if line.startswith(f"│ {key}")) + return [cell.strip() for cell in row.split("│")[1:-1]] + + # ── list ───────────────────────────────────────────────────────────── @@ -80,6 +92,39 @@ def test_list_shows_available_integrations(self, tmp_path): assert "claude" in result.output assert "gemini" in result.output + def test_list_shows_multi_install_safe_status(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + assert "Multi-install" in result.output + assert "Safe" in result.output + assert _integration_list_row_cells(result.output, "claude")[-1] == "yes" + assert _integration_list_row_cells(result.output, "copilot")[-1] == "no" + + def test_list_rejects_newer_integration_state_schema(self, tmp_path): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + data = json.loads(int_json.read_text(encoding="utf-8")) + data["integration_state_schema"] = 99 + int_json.write_text(json.dumps(data), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "list"]) + finally: + os.chdir(old_cwd) + + assert result.exit_code != 0 + normalized = " ".join(result.output.split()) + assert "schema 99" in normalized + assert "only supports schema 1" in normalized + # ── install ────────────────────────────────────────────────────────── @@ -116,7 +161,9 @@ def test_install_already_installed(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 assert "already installed" in result.output - assert "uninstall" in result.output + normalized = " ".join(result.output.split()) + assert "specify integration upgrade copilot" in normalized + assert "specify integration uninstall copilot" in normalized def test_install_different_when_one_exists(self, tmp_path): project = _init_project(tmp_path, "copilot") @@ -127,8 +174,112 @@ def test_install_different_when_one_exists(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code != 0 - assert "already installed" in result.output - assert "uninstall" in result.output + assert "Installed integrations: copilot" in result.output + assert "Default integration: copilot" in result.output + assert "--force" in result.output + + def test_install_multi_safe_integration(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "installed successfully" in result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert data["default_integration"] == "claude" + assert data["integration_state_schema"] == 1 + assert data["installed_integrations"] == ["claude", "codex"] + assert data["integration_settings"]["claude"]["invoke_separator"] == "-" + assert data["integration_settings"]["codex"]["invoke_separator"] == "-" + + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + + def test_install_additional_preserves_shared_manifest(self, tmp_path): + project = _init_project(tmp_path, "claude") + shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" + before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"]) + assert before + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"]) + assert before <= after + + def test_install_multi_safe_migrates_legacy_state(self, tmp_path): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + int_json.write_text(json.dumps({ + "integration": "claude", + "version": "0.0.0", + }), encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads(int_json.read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert data["default_integration"] == "claude" + assert data["installed_integrations"] == ["claude", "codex"] + + def test_install_multi_unsafe_requires_force(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Installed integrations: copilot" in result.output + assert "multi-install safe" in result.output + assert "--force" in result.output + + def test_install_multi_unsafe_allowed_with_force(self, tmp_path): + project = _init_project(tmp_path, "copilot") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "copilot" + assert data["installed_integrations"] == ["copilot", "claude"] def test_install_into_bare_project(self, tmp_path): """Install into a project with .specify/ but no integration.""" @@ -246,6 +397,7 @@ def test_uninstall_preserves_modified_files(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 assert "preserved" in result.output + assert ".claude/skills/speckit-plan/SKILL.md" in result.output # Modified file kept assert plan_file.exists() @@ -260,7 +412,68 @@ def test_uninstall_wrong_key(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code != 0 - assert "not the currently installed" in result.output + assert "not installed" in result.output + + def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path): + project = _init_project(tmp_path, "claude") + _write_invalid_manifest(project, "claude") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "uninstall", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "manifest" in result.output + assert "unreadable" in result.output + + def test_uninstall_non_default_preserves_default(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "uninstall", "codex", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists() + assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists() + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert data["installed_integrations"] == ["claude"] + + def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path): + project = _init_project(tmp_path, "gemini") + template = project / ".specify" / "templates" / "plan-template.md" + assert "/speckit.plan" in template.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "claude" + assert "/speckit-plan" in template.read_text(encoding="utf-8") def test_uninstall_preserves_shared_infra(self, tmp_path): """Shared scripts and templates are not removed by integration uninstall.""" @@ -281,6 +494,135 @@ def test_uninstall_preserves_shared_infra(self, tmp_path): assert (project / ".specify" / "templates").is_dir() +class TestIntegrationUse: + def test_use_installed_integration_sets_default(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "codex" + assert data["default_integration"] == "codex" + assert data["installed_integrations"] == ["claude", "codex"] + + opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert opts["integration"] == "codex" + assert opts["ai"] == "codex" + + def test_use_requires_installed_integration(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "use", "codex"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "not installed" in result.output + + def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path): + project = _init_project(tmp_path, "claude") + template = project / ".specify" / "templates" / "plan-template.md" + assert "/speckit-plan" in template.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "gemini", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) + assert use_gemini.exit_code == 0, use_gemini.output + assert "/speckit.plan" in template.read_text(encoding="utf-8") + + use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False) + assert use_claude.exit_code == 0, use_claude.output + assert "/speckit-plan" in template.read_text(encoding="utf-8") + finally: + os.chdir(old_cwd) + + def test_use_preserves_modified_templates_unless_forced(self, tmp_path): + project = _init_project(tmp_path, "claude") + template = project / ".specify" / "templates" / "plan-template.md" + template.write_text("custom template with /speckit-plan\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "gemini", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False) + assert use_gemini.exit_code == 0, use_gemini.output + assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n" + + force_use = runner.invoke(app, [ + "integration", "use", "gemini", + "--force", + ], catch_exceptions=False) + assert force_use.exit_code == 0, force_use.output + finally: + os.chdir(old_cwd) + + updated = template.read_text(encoding="utf-8") + assert "/speckit.plan" in updated + assert "custom template" not in updated + + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable") + def test_use_does_not_persist_default_when_template_refresh_fails(self, tmp_path): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + init_options = project / ".specify" / "init-options.json" + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + before_state = json.loads(int_json.read_text(encoding="utf-8")) + before_options = json.loads(init_options.read_text(encoding="utf-8")) + + outside = tmp_path / "outside-template.md" + outside.write_text("# outside\n", encoding="utf-8") + template = project / ".specify" / "templates" / "plan-template.md" + template.unlink() + os.symlink(outside, template) + + result = runner.invoke(app, [ + "integration", "use", "codex", + "--force", + ]) + finally: + os.chdir(old_cwd) + + assert result.exit_code != 0 + assert "Failed to refresh shared templates" in result.output + assert json.loads(int_json.read_text(encoding="utf-8")) == before_state + assert json.loads(init_options.read_text(encoding="utf-8")) == before_options + assert outside.read_text(encoding="utf-8") == "# outside\n" + + # ── switch ─────────────────────────────────────────────────────────── @@ -306,6 +648,22 @@ def test_switch_unknown_target(self, tmp_path): assert result.exit_code != 0 assert "Unknown integration" in result.output + def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path): + project = _init_project(tmp_path, "claude") + _write_invalid_manifest(project, "claude") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "codex", + "--script", "sh", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "Could not read integration manifest" in result.output + def test_switch_same_noop(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() @@ -315,7 +673,48 @@ def test_switch_same_noop(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - assert "already installed" in result.output + assert "already the default integration" in result.output + + def test_switch_same_force_refreshes_shared_templates(self, tmp_path): + project = _init_project(tmp_path, "claude") + template = project / ".specify" / "templates" / "plan-template.md" + template.write_text("# custom shared template\n", encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "integration", "switch", "claude", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + assert "managed shared templates refreshed" in result.output + assert "/speckit-plan" in template.read_text(encoding="utf-8") + + def test_switch_installed_target_rejects_integration_options(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "switch", "codex", + "--integration-options", "--bogus", + ]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "--integration-options cannot be used" in result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["default_integration"] == "claude" def test_switch_between_integrations(self, tmp_path): project = _init_project(tmp_path, "claude") @@ -522,6 +921,107 @@ def test_switch_from_nothing(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "claude" + def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path): + project = _init_project(tmp_path, "claude") + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "codex", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "switch", "generic", + "--script", "sh", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "codex" + assert data["installed_integrations"] == ["codex"] + + opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) + assert opts["integration"] == "codex" + assert opts["ai"] == "codex" + + template = project / ".specify" / "templates" / "plan-template.md" + assert "/speckit-plan" in template.read_text(encoding="utf-8") + + +class TestIntegrationUpgrade: + def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): + project = _init_project(tmp_path, "claude") + _write_invalid_manifest(project, "claude") + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["integration", "upgrade", "claude"]) + finally: + os.chdir(old_cwd) + assert result.exit_code != 0 + assert "manifest" in result.output + assert "unreadable" in result.output + + def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch): + project = _init_project(tmp_path, "claude") + int_json = project / ".specify" / "integration.json" + init_options = project / ".specify" / "init-options.json" + manifest_path = project / ".specify" / "integrations" / "claude.manifest.json" + + before_state = json.loads(int_json.read_text(encoding="utf-8")) + before_options = json.loads(init_options.read_text(encoding="utf-8")) + before_manifest = manifest_path.read_text(encoding="utf-8") + + import specify_cli + + def fail_refresh(*args, **kwargs): + raise ValueError("refuse refresh") + + monkeypatch.setattr(specify_cli, "_refresh_shared_templates", fail_refresh) + + result = _run_in_project(project, [ + "integration", "upgrade", "claude", + "--force", + ]) + + assert result.exit_code != 0 + assert "Failed to refresh shared templates" in result.output + assert json.loads(int_json.read_text(encoding="utf-8")) == before_state + assert json.loads(init_options.read_text(encoding="utf-8")) == before_options + assert manifest_path.read_text(encoding="utf-8") == before_manifest + + def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path): + project = _init_project(tmp_path, "gemini") + template = project / ".specify" / "templates" / "plan-template.md" + assert "/speckit.plan" in template.read_text(encoding="utf-8") + + old_cwd = os.getcwd() + try: + os.chdir(project) + install = runner.invoke(app, [ + "integration", "install", "claude", + "--script", "sh", + ], catch_exceptions=False) + assert install.exit_code == 0, install.output + + result = runner.invoke(app, [ + "integration", "upgrade", "claude", + "--script", "sh", + "--force", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0, result.output + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == "gemini" + assert "/speckit.plan" in template.read_text(encoding="utf-8") + # ── Full lifecycle ─────────────────────────────────────────────────── diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 8ab1425148..1b36501056 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -1,7 +1,13 @@ """Tests for INTEGRATION_REGISTRY — mechanics, completeness, and registrar alignment.""" +import json +import os +from pathlib import PurePosixPath + import pytest +from typer.testing import CliRunner +from specify_cli import app from specify_cli.integrations import ( INTEGRATION_REGISTRY, _register, @@ -25,6 +31,72 @@ ] +def _multi_install_safe_keys() -> list[str]: + return sorted( + key + for key, integration in INTEGRATION_REGISTRY.items() + if integration.multi_install_safe + ) + + +def _multi_install_safe_pairs() -> list[tuple[str, str]]: + safe_keys = _multi_install_safe_keys() + return [ + (safe_keys[left], safe_keys[right]) + for left in range(len(safe_keys)) + for right in range(left + 1, len(safe_keys)) + ] + + +def _posix_path(value: str | None) -> str | None: + if not value: + return None + return PurePosixPath(value).as_posix() + + +def _integration_root_dir(key: str) -> str | None: + integration = INTEGRATION_REGISTRY[key] + cfg = integration.config if isinstance(integration.config, dict) else {} + return _posix_path(cfg.get("folder")) + + +def _integration_commands_dir(key: str) -> str | None: + integration = INTEGRATION_REGISTRY[key] + cfg = integration.config if isinstance(integration.config, dict) else {} + folder = cfg.get("folder") + if not folder: + return None + subdir = cfg.get("commands_subdir", "commands") + return (PurePosixPath(folder) / subdir).as_posix() + + +def _paths_overlap(first: str | None, second: str | None) -> bool: + if not first or not second: + return False + left = PurePosixPath(first) + right = PurePosixPath(second) + try: + left.relative_to(right) + return True + except ValueError: + pass + try: + right.relative_to(left) + return True + except ValueError: + return False + + +def _path_is_inside(path: str | None, directory: str | None) -> bool: + if not path or not directory: + return False + try: + PurePosixPath(path).relative_to(PurePosixPath(directory)) + return True + except ValueError: + return False + + class TestRegistry: def test_registry_is_dict(self): assert isinstance(INTEGRATION_REGISTRY, dict) @@ -85,3 +157,134 @@ def test_no_stale_cursor_shorthand(self): """The old 'cursor' shorthand must not appear in AGENT_CONFIGS.""" from specify_cli.agents import CommandRegistrar assert "cursor" not in CommandRegistrar.AGENT_CONFIGS + + +class TestMultiInstallSafeContracts: + """Declared safe integrations must stay isolated from each other.""" + + @pytest.mark.parametrize("key", _multi_install_safe_keys()) + def test_safe_integrations_have_static_isolated_paths(self, key): + integration = INTEGRATION_REGISTRY[key] + + assert _integration_root_dir(key), ( + f"{key} is declared multi-install safe but has no static root directory" + ) + assert _integration_commands_dir(key), ( + f"{key} is declared multi-install safe but has no static commands directory" + ) + assert integration.context_file, ( + f"{key} is declared multi-install safe but has no context file" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_integrations_have_distinct_agent_roots(self, first, second): + assert not _paths_overlap(_integration_root_dir(first), _integration_root_dir(second)), ( + f"{first} and {second} are declared multi-install safe but have " + f"overlapping agent roots {_integration_root_dir(first)!r} and " + f"{_integration_root_dir(second)!r}" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_integrations_have_distinct_command_dirs(self, first, second): + assert not _paths_overlap(_integration_commands_dir(first), _integration_commands_dir(second)), ( + f"{first} and {second} are declared multi-install safe but have " + f"overlapping command directories {_integration_commands_dir(first)!r} and " + f"{_integration_commands_dir(second)!r}" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_integrations_have_distinct_context_files(self, first, second): + first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) + second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) + + assert first_context != second_context, ( + f"{first} and {second} are declared multi-install safe but share " + f"context file {first_context!r}" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_context_files_do_not_overlap_other_agent_roots(self, first, second): + first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) + second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) + + assert not _path_is_inside(first_context, _integration_root_dir(second)), ( + f"{first} context file {first_context!r} lives under {second} " + f"agent root {_integration_root_dir(second)!r}" + ) + assert not _path_is_inside(second_context, _integration_root_dir(first)), ( + f"{second} context file {second_context!r} lives under {first} " + f"agent root {_integration_root_dir(first)!r}" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_context_files_do_not_overlap_other_command_dirs(self, first, second): + first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) + second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) + + assert not _path_is_inside(first_context, _integration_commands_dir(second)), ( + f"{first} context file {first_context!r} lives under {second} " + f"commands directory {_integration_commands_dir(second)!r}" + ) + assert not _path_is_inside(second_context, _integration_commands_dir(first)), ( + f"{second} context file {second_context!r} lives under {first} " + f"commands directory {_integration_commands_dir(first)!r}" + ) + + @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) + def test_safe_integrations_have_disjoint_manifests( + self, + tmp_path, + first, + second, + ): + for initial, additional in ((first, second), (second, first)): + project_root = tmp_path / f"project-{initial}-{additional}" + project_root.mkdir() + runner = CliRunner() + + original_cwd = os.getcwd() + try: + os.chdir(project_root) + init_result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + initial, + "--script", + "sh", + "--no-git", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + assert init_result.exit_code == 0, init_result.output + + install_result = runner.invoke( + app, + ["integration", "install", additional, "--script", "sh"], + catch_exceptions=False, + ) + assert install_result.exit_code == 0, install_result.output + finally: + os.chdir(original_cwd) + + initial_manifest = json.loads( + ( + project_root / ".specify" / "integrations" / f"{initial}.manifest.json" + ).read_text(encoding="utf-8") + ) + additional_manifest = json.loads( + ( + project_root / ".specify" / "integrations" / f"{additional}.manifest.json" + ).read_text(encoding="utf-8") + ) + + initial_files = set(initial_manifest.get("files", {})) + additional_files = set(additional_manifest.get("files", {})) + + assert initial_files.isdisjoint(additional_files), ( + f"{initial} and {additional} are declared multi-install safe but both manage " + f"these files: {sorted(initial_files & additional_files)}" + ) diff --git a/tests/test_presets.py b/tests/test_presets.py index 4b167ed9be..848c072dd0 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -14,6 +14,7 @@ import json import tempfile import shutil +import warnings import zipfile from pathlib import Path from datetime import datetime, timezone @@ -1921,6 +1922,10 @@ def test_url_cache_expired(self, project_dir): SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test" +SELF_TEST_WRAP_WARNING = ( + r"Cannot compose command 'speckit\.wrap-test': no base layer\. " + r"Stale command files may remain\." +) CORE_TEMPLATE_NAMES = [ "spec-template", @@ -1931,6 +1936,18 @@ def test_url_cache_expired(self, project_dir): ] +def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1.5") -> PresetManifest: + """Install self-test while filtering its intentionally missing wrap base.""" + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=SELF_TEST_WRAP_WARNING, + category=UserWarning, + module=r"specify_cli\.presets", + ) + return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version) + + class TestSelfTestPreset: """Tests using the self-test preset that ships with the repo.""" @@ -1971,7 +1988,7 @@ def test_self_test_templates_have_marker(self): def test_install_self_test_preset(self, project_dir): """Test installing the self-test preset from its directory.""" manager = PresetManager(project_dir) - manifest = manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + manifest = install_self_test_preset(manager) assert manifest.id == "self-test" assert manager.registry.is_installed("self-test") @@ -1984,7 +2001,7 @@ def test_self_test_overrides_all_core_templates(self, project_dir): # Install self-test preset manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) # Every core template should now resolve from the preset resolver = PresetResolver(project_dir) @@ -2003,7 +2020,7 @@ def test_self_test_resolve_with_source(self, project_dir): (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) resolver = PresetResolver(project_dir) for name in CORE_TEMPLATE_NAMES: @@ -2020,7 +2037,7 @@ def test_self_test_removal_restores_core(self, project_dir): (templates_dir / f"{name}.md").write_text(f"# Core {name}\n") manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) manager.remove("self-test") resolver = PresetResolver(project_dir) @@ -2056,7 +2073,7 @@ def test_self_test_registers_commands_for_claude(self, project_dir): claude_dir.mkdir(parents=True) manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) # Check the skill was registered cmd_file = claude_dir / "speckit-specify" / "SKILL.md" @@ -2072,7 +2089,7 @@ def test_self_test_registers_commands_for_gemini(self, project_dir): gemini_dir.mkdir(parents=True) manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) # Check the command was registered in TOML format cmd_file = gemini_dir / "speckit.specify.toml" @@ -2087,7 +2104,7 @@ def test_self_test_unregisters_commands_on_remove(self, project_dir): claude_dir.mkdir(parents=True) manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) cmd_file = claude_dir / "speckit-specify" / "SKILL.md" assert cmd_file.exists() @@ -2098,7 +2115,7 @@ def test_self_test_unregisters_commands_on_remove(self, project_dir): def test_self_test_no_commands_without_agent_dirs(self, project_dir): """Test that no commands are registered when no agent dirs exist.""" manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) metadata = manager.registry.get("self-test") assert metadata["registered_commands"] == {} @@ -2247,8 +2264,7 @@ def test_skill_overridden_on_preset_install(self, project_dir, temp_dir): # Install self-test preset (has a command override for speckit.specify) manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert skill_file.exists() @@ -2267,8 +2283,7 @@ def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir): self._create_skill(skills_dir, "speckit-specify", body="untouched") manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" content = skill_file.read_text() @@ -2300,8 +2315,7 @@ def test_skill_not_updated_without_init_options(self, project_dir, temp_dir): self._create_skill(skills_dir, "speckit-specify", body="untouched") manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" file_content = skill_file.read_text() @@ -2321,8 +2335,7 @@ def test_skill_restored_on_preset_remove(self, project_dir, temp_dir): (core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n") manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) # Verify preset content is in the skill skill_file = skills_dir / "speckit-specify" / "SKILL.md" @@ -2358,8 +2371,7 @@ def test_skill_restored_on_remove_resolves_script_placeholders(self, project_dir ) manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) manager.remove("self-test") content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() @@ -2375,8 +2387,7 @@ def test_skill_not_overridden_when_skill_path_is_file(self, project_dir): (skills_dir / "speckit-specify").write_text("not-a-directory") manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) assert (skills_dir / "speckit-specify").is_file() metadata = manager.registry.get("self-test") @@ -2388,8 +2399,7 @@ def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_d # Don't create skills dir — simulate --ai-skills never created them manager = PresetManager(project_dir) - SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(SELF_TEST_DIR, "0.1.5") + install_self_test_preset(manager) metadata = manager.registry.get("self-test") assert metadata.get("registered_skills", []) == [] @@ -2590,8 +2600,7 @@ def test_kimi_legacy_dotted_skill_override_still_applies(self, project_dir, temp (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) - self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(self_test_dir, "0.1.5") + install_self_test_preset(manager) skill_file = skills_dir / "speckit.specify" / "SKILL.md" assert skill_file.exists() @@ -2611,8 +2620,7 @@ def test_kimi_skill_updated_even_when_ai_skills_disabled(self, project_dir, temp (project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True) manager = PresetManager(project_dir) - self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(self_test_dir, "0.1.5") + install_self_test_preset(manager) skill_file = skills_dir / "speckit-specify" / "SKILL.md" assert skill_file.exists() @@ -2791,8 +2799,7 @@ def test_preset_skill_registration_handles_non_dict_init_options(self, project_d self._create_skill(skills_dir, "speckit-specify", body="untouched") manager = PresetManager(project_dir) - self_test_dir = Path(__file__).parent.parent / "presets" / "self-test" - manager.install_from_directory(self_test_dir, "0.1.5") + install_self_test_preset(manager) skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text() assert "untouched" in skill_content @@ -3451,7 +3458,7 @@ def test_end_to_end_wrap_via_self_test_preset(self, project_dir): ) manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) written = (skill_subdir / "SKILL.md").read_text() assert "{CORE_TEMPLATE}" not in written @@ -3503,7 +3510,7 @@ def test_register_skills_inherits_scripts_from_core_when_preset_omits_them(self, ) manager = PresetManager(project_dir) - manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5") + install_self_test_preset(manager) written = (skill_subdir / "SKILL.md").read_text() # {SCRIPT} should have been resolved (not left as a literal placeholder) From 6546026626d75eab5f6939e449a7f8d67e15ac35 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Fri, 1 May 2026 23:55:55 +0700 Subject: [PATCH 155/184] Update DyanGalih(Memory Hub and Security Review) community extensions (#2429) * chore: update DyanGalih extensions to latest versions * chore: update catalog root timestamp to current --- extensions/catalog.community.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index ef68ce9c19..96d497d178 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-30T09:00:00Z", + "updated_at": "2026-05-01T15:01:47Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1280,8 +1280,8 @@ "id": "memory-md", "description": "Repository-native durable memory for Spec Kit projects", "author": "DyanGalih", - "version": "0.6.2", - "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.2.zip", + "version": "0.6.9", + "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.9.zip", "repository": "https://github.com/DyanGalih/spec-kit-memory-hub", "homepage": "https://github.com/DyanGalih/spec-kit-memory-hub", "documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md", @@ -1305,7 +1305,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-23T00:00:00Z", - "updated_at": "2026-04-23T00:00:00Z" + "updated_at": "2026-05-01T14:48:00Z" }, "memorylint": { "name": "MemoryLint", @@ -1931,8 +1931,8 @@ "id": "security-review", "description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews", "author": "DyanGalih", - "version": "1.3.0", - "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.3.0.zip", + "version": "1.3.3", + "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.3.3.zip", "repository": "https://github.com/DyanGalih/spec-kit-security-review", "homepage": "https://github.com/DyanGalih/spec-kit-security-review", "documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md", @@ -1956,7 +1956,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-03T03:24:03Z", - "updated_at": "2026-04-29T00:00:00Z" + "updated_at": "2026-05-01T14:48:00Z" }, "sf": { "name": "SFSpeckit — Salesforce Spec-Driven Development", From 822a0e5c61844094724cb803fb0c9b27e4a4bea4 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 1 May 2026 13:06:42 -0500 Subject: [PATCH 156/184] feat: emit init-time notice for git extension default change (#2165) (#2432) Add a non-blocking Panel notice during `specify init` when the git extension auto-enables, informing users that starting in v0.10.0 this will require explicit opt-in via `specify extension add git`. - src/specify_cli/__init__.py: track successful git extension install and display yellow "Notice: Git Default Changing" panel - tests/integrations/test_cli.py: integration test validating notice content (v0.10.0 timeline, opt-in messaging, migration command) - docs/reference/core.md: user-facing NOTE about the upcoming change Closes #2165 --- docs/reference/core.md | 4 ++++ src/specify_cli/__init__.py | 15 +++++++++++++++ tests/integrations/test_cli.py | 26 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/docs/reference/core.md b/docs/reference/core.md index fdab05a02b..aeef06ab79 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -22,6 +22,10 @@ specify init [] Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files. +> [!NOTE] +> The git extension is currently enabled by default during `specify init`. +> Starting in `v0.10.0`, it will require explicit opt-in. To add it after init, run `specify extension add git`. + Use `` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation. ### Examples diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 6d0181091d..ccd670d20e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1264,6 +1264,8 @@ def init( ]: tracker.add(key, label) + git_default_notice = False + with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: tracker.attach_refresh(lambda: live.update(tracker.render())) try: @@ -1360,6 +1362,7 @@ def init( manager.install_from_directory( bundled_path, get_speckit_version() ) + git_default_notice = True git_messages.append("extension installed") else: git_has_error = True @@ -1530,6 +1533,18 @@ def init( console.print() console.print(deprecation_notice) + if git_default_notice: + default_change_notice = Panel( + "The git extension is currently enabled by default during [bold]specify init[/bold].\n" + "Starting in [bold]v0.10.0[/bold], this will require explicit opt-in.\n" + "Use [bold]specify extension add git[/bold] after init when needed.", + title="[yellow]Notice: Git Default Changing[/yellow]", + border_style="yellow", + padding=(1, 2), + ) + console.print() + console.print(default_change_notice) + steps_lines = [] if not here: steps_lines.append(f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]") diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index bb17c86bc1..7732d57300 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -785,6 +785,32 @@ def test_no_git_emits_deprecation_warning(self, tmp_path): assert "will be removed" in normalized_output assert "git extension will no longer be enabled by default" in normalized_output + def test_default_git_auto_enable_emits_notice(self, tmp_path): + """Default git auto-enable emits notice about the v0.10.0 opt-in change.""" + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "git-default-notice" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + runner = CliRunner() + result = runner.invoke(app, [ + "init", "--here", "--ai", "claude", "--script", "sh", + "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + normalized_output = _normalize_cli_output(result.output) + assert result.exit_code == 0, result.output + # Check for key message components (notice may have box-drawing chars) + assert "git extension is currently enabled by default" in normalized_output + assert "v0.10.0" in normalized_output + assert "explicit opt-in" in normalized_output + assert "specify extension add git" in normalized_output + def test_git_extension_commands_registered(self, tmp_path): """Git extension commands are registered with the agent during init.""" from typer.testing import CliRunner From f60e28ddbab289263bebe766995f9420b2e01119 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 1 May 2026 13:38:25 -0500 Subject: [PATCH 157/184] docs: add April 2026 newsletter (#2434) --- newsletters/2026-April.md | 147 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 newsletters/2026-April.md diff --git a/newsletters/2026-April.md b/newsletters/2026-April.md new file mode 100644 index 0000000000..913dedaf23 --- /dev/null +++ b/newsletters/2026-April.md @@ -0,0 +1,147 @@ +# Spec Kit - April 2026 Newsletter + +This edition covers Spec Kit activity in April 2026. Seventeen releases shipped (v0.4.4 through v0.8.3), delivering a full integration plugin architecture, a workflow engine, preset composition strategies, an integration catalog, and comprehensive documentation. The community extension catalog tripled from 26 to 83 entries, community presets grew from 2 to 12, and Spec Kit appeared on the Thoughtworks Technology Radar. A summary is in the table below, followed by details. + +| **Spec Kit Core (Apr 2026)** | **Community & Content** | **SDD Ecosystem & Next** | +| --- | --- | --- | +| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. 12 substantive external articles published. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. | + +*** + +> **Important:** April's release pace outran external coverage. Most analyses published during the month (Rickard on April 1, Thoughtworks Radar on April 15, XB Software on April 17, Torber on April 23) were evaluating versions that predated the workflow engine (v0.7.0), integration catalog (v0.7.2), preset composition (v0.8.0), and catalog discovery CLI (v0.8.3). The ceremony and flexibility concerns they raised are precisely what these features address — the lean preset, pluggable workflows, composable presets, and community extensions like Conduct, MAQA, and Fleet Orchestrator already deliver alternative workflows beyond the default SDD process. We look forward to seeing how upcoming reviews account for these capabilities. + +## Spec Kit Project Updates + +### Releases Overview + +**v0.4.4** (April 1) delivered the first stage of the **integration plugin architecture** — base classes, a manifest system, and a registry that replaced the hard-coded agent scaffolding. It also added the Product Forge, Superpowers Bridge, MAQA suite (7 extensions), Spec Kit Onboard, and Plan Review Gate to the community catalog, fixed Claude Code CLI detection for npm-local installs, and added `--allow-existing-branch` to `create-new-feature`. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.4.4) + +**v0.4.5** (April 2) completed the integration migration in five stages: standard markdown integrations for 19 agents, TOML integrations (Gemini, Tabnine), skills and generic integrations, and removal of the legacy scaffold path. It also installed Claude Code as native skills, added a `--dry-run` flag for `create-new-feature`, support for 4+ digit feature branch numbers, the Fix Findings extension, and five lifecycle extensions to the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.4.5) + +**v0.5.0** (April 2) was a significant packaging change: **template zip bundles were removed from releases**, with the CLI itself now handling all scaffolding. This ensured CLI and templates stay in sync. It also introduced `DEVELOPMENT.md` for contributor onboarding. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.5.0) + +**v0.5.1** (April 8) was a large patch release. It added the **bundled Git extension** (stages 1 and 2) with hooks on all core commands and `GIT_BRANCH_NAME` override support, **Forgecode** agent support, and the `specify integration` subcommand for post-init integration management. Argument hints were added to Claude Code commands. Numerous community extensions joined the catalog (Confluence, Canon, Spec Diagram, Branch Convention, Spec Refine, FixIt, Optimize, Security Review) along with presets (explicit-task-dependencies, toc-navigation, VS Code Ask Questions). Bug fixes included pinning typer≥0.24.0/click≥8.2.1 to fix an import crash, BSD-portable sed escaping, Trae agent fix, TOML frontmatter stripping, and preventing ambiguous TOML closing quotes. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.5.1) + +**v0.6.0** (April 9) rewrote **AGENTS.md for the new integration architecture**, added the SpecKit Companion to Community Friends, and brought Bugfix Workflow, Worktree Isolation, and MemoryLint to the community catalog. A new multi-repo-branching preset arrived. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.0) + +**v0.6.1** (April 10) added the **bundled lean preset** with a minimal workflow command set — a lighter-weight alternative to the full SDD ceremony. It also migrated **Cursor** from `.cursor/commands` to `.cursor/skills` and added Brownfield Bootstrap, CI Guard, SpecTest, PR Bridge, TinySpec, and Status Report to the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.1) + +**v0.6.2** (April 13) added **Goose AI agent** support (YAML-based recipe format), the GitHub Issues Integration extension, and the What-if Analysis extension. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.2) + +**v0.7.0** (April 14) delivered the **workflow engine with catalog system**, enabling pluggable, multi-step workflow definitions. It added SFSpeckit (Salesforce SDD), the Worktrees extension, optional single-segment branch prefix for gitflow compatibility, and the claude-ask-questions and fiction-book-writing presets. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.0) + +**v0.7.1** (April 15) deprecated the `--ai` flag in favor of `--integration` on `specify init`, added Windows to the CI test matrix, fixed Claude skill chaining for hook execution, merged TESTING.md into CONTRIBUTING.md, and added the Agent Assign and Architect Preview extensions. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.1) + +**v0.7.2** (April 16) delivered the **integration catalog** for discovery, versioning, and community distribution of agent integrations. It also produced a major **documentation overhaul**: reference pages for core commands, extensions, presets, workflows, and integrations were added to `docs/reference/`, and the README CLI section was simplified. The Issues extension and Catalog CI extension joined the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.2) + +**v0.7.3** (April 17) replaced shell-based context updates with a **marker-based upsert** mechanism, eliminating accidental context file bloat. It added a **Community Friends page** to the docs site, the Spec Scope and Blueprint extensions, and a Claude Code/Copilot CLI plugin marketplace reference in the README. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.3) + +**v0.7.4** (April 21) added **CITATION.cff and .zenodo.json** for academic citation support. It introduced Ripple (side-effect detection), Spec Validate, Version Guard, Spec Reference Loader, and Memory Loader extensions. A fix stripped UTF-8 BOM from agent context files, and the Antigravity (agy) agent layout was migrated to `.agents/` with `--skills` deprecated. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.4) + +**v0.7.5** (April 22) added `specify self check` and `self upgrade` stubs, the **preset wrap strategy** (completing the composition trifecta alongside prepend and append), the Red Team adversarial review extension, the Wireframe extension, and a **directory traversal security fix** in command write paths. Skill placeholder resolution was expanded to all SKILL.md agents. Community content (walkthroughs and presets) was moved from the README to the docs site. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.5) + +**v0.8.0** (April 23) delivered **preset composition strategies** (prepend, append, wrap) for templates, commands, and scripts — enabling presets to layer content around existing artifacts. It also added Copilot `--integration-options="--skills"` for skills-based scaffolding, `pipx` as an alternative installation method, and the Memory MD extension. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.0) + +**v0.8.1** (April 24) fixed `/speckit.plan` on custom git branches via `.specify/feature.json`, migrated the **Mistral Vibe** integration to SkillsIntegration, added the **Screenwriting** and **Jira** presets, and resolved command reference formats per integration type (dot vs. hyphen notation). [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.1) + +**v0.8.2** (April 28) introduced **GITHUB_TOKEN/GH_TOKEN authentication** for private catalog and extension downloads, deprecated the `--no-git` flag (removal gated at v0.10.0), replaced all deprecated `--ai` references with `--integration` in documentation, and added MarkItDown Document Converter, Microsoft 365 Integration, Spec Orchestrator, and the Fiction Book Writing v1.7 preset with RAG (Chroma DB) offline semantic search. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.2) + +**v0.8.3** (April 29) closed the month with **catalog discovery CLI commands** (search, info, catalog list/add/remove), support for **Devin for Terminal** as a skills-based integration, a fix for the opencode command dispatch, and the OWASP LLM Threat Model, iSAQB Architecture Governance, and Work IQ extensions. A fix was also added to the upgrade hint to prevent users from accidentally installing a PyPI squat package. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.3) + +### Architecture & Infrastructure Highlights + +The most significant architectural change in April was the **integration plugin architecture** (v0.4.4–v0.4.5), which replaced hard-coded agent scaffolding with a registry of self-describing integration classes. Each agent is now a self-contained subpackage under `src/specify_cli/integrations//` with base classes for Markdown, TOML, YAML, and Skills formats. This six-stage migration touched all 28 supported agents and laid the groundwork for the integration catalog (v0.7.2) and community-distributed integrations. + +The **workflow engine** (v0.7.0) introduced a catalog-based system for pluggable, multi-step workflow definitions — moving beyond the fixed seven-step SDD sequence. + +**Preset composition strategies** (v0.7.5/v0.8.0) completed the preset system with prepend, append, and wrap modes. Presets can now layer content around existing templates, commands, and scripts rather than only replacing them. + +The **marker-based context upsert** (v0.7.3) replaced fragile shell-based sed operations for updating agent context files, eliminating a class of bugs around context bloat and encoding issues. + +**Template zip bundles were removed** (v0.5.0), coupling the CLI and templates into a single distributable artifact. + +### Bug Fixes and Security + +The most critical fix was **blocking directory traversal in command write paths** (#2229, v0.7.5), which prevented a potential path traversal vulnerability in the CommandRegistrar. Other security-adjacent fixes included hardening against a **PyPI squat package** in upgrade hints (v0.8.3) and adding **GITHUB_TOKEN authentication** for private catalog downloads (v0.8.2). + +Notable bug fixes: typer/click import crash (v0.5.1), BSD-portable sed escaping (v0.5.1), UTF-8 BOM stripping from context files (v0.7.4), CRLF warning suppression in PowerShell auto-commit (v0.7.3), Claude skill chaining for hooks (v0.7.1), TOML ambiguous closing quotes (v0.5.1), and custom branch support for `/speckit.plan` (v0.8.1). [\[github.com\]](https://github.com/github/spec-kit/releases) + +### The Extension & Preset Ecosystem + +The community extension catalog **tripled** during April, growing from 26 to **83 entries**. 59 new extensions were added and 2 were removed (Cognitive Squad and Understanding, whose repositories were no longer available). Community presets grew from 2 to **12 entries**, with 10 new presets added. + +Notable new extensions by category: + +- **Project management**: GitHub Issues Integration (Fatima367, aaronrsun), Spec Orchestrator (Quratulain-bilal), Agent Assign (xuyang), Status Report (Open-Agent-Tools) +- **Quality & security**: Red Team adversarial review (Ash Brener), Security Review (DyanGalih), Ripple side-effect detection (chordpli), Spec Validate (Ahmed Eltayeb), CI Guard (Quratulain-bilal), OWASP LLM Threat Model (NaviaSamal) +- **Multi-agent & orchestration**: MAQA suite with 7 extensions covering multi-agent QA, Jira, Azure DevOps, GitHub Projects, Linear, and Trello integrations (GenieRobot), Product Forge (VaiYav) +- **Spec lifecycle**: Spec Refine (Quratulain-bilal), Bugfix Workflow (Quratulain-bilal), Fix Findings (Quratulain-bilal), Brownfield Bootstrap (Quratulain-bilal), TinySpec (Quratulain-bilal) +- **Developer experience**: Blueprint code review (chordpli), Confluence (aaronrsun), MarkItDown Document Converter (BenBtg), Microsoft 365 Integration (BenBtg), Memory MD (DyanGalih), Memory Loader (KevinBrown5280), MemoryLint (RbBtSn0w) +- **Domain-specific**: SFSpeckit for Salesforce (Sumanth Yanamala), iSAQB Architecture Governance preset (Thorsten Hindermann), Canon baseline-driven workflows (Maxim Stupakov) +- **Creative**: Fiction Book Writing preset v1.7 with RAG/Chroma DB support (Andreas Daumann), Screenwriting preset (Andreas Daumann) + +Notable contributor **Quratulain-bilal** contributed 15 extensions during the month, spanning spec lifecycle, workflow management, and CI/CD integration. **GenieRobot** contributed the 7-extension MAQA suite. **BenBtg** contributed both MarkItDown and Microsoft 365 integrations. [\[github.com\]](https://github.com/github/spec-kit/releases) + +### Documentation Overhaul + +April saw a comprehensive documentation effort. Reference pages for **core commands, extensions, presets, workflows, and integrations** were created under `docs/reference/`. Community content — **walkthroughs, presets, and a Community Friends page** — was moved from the README to `docs/community/`, reducing README length while improving discoverability. The deprecated `--ai` flag references were replaced with `--integration` across all documentation. TESTING.md was merged into CONTRIBUTING.md, and `DEVELOPMENT.md` was introduced for contributor onboarding. [\[github.com\]](https://github.com/github/spec-kit/releases) + +## Community & Content + +### Thoughtworks Technology Radar + +On **April 15**, the **Thoughtworks Technology Radar Volume 34** placed GitHub Spec Kit in the **"Assess" ring** under Languages & Frameworks. The blip noted that teams report value in brownfield projects, that the constitution captures project scope and architecture, but flagged potential **instruction bloat, context rot, and verbose markdown output** as concerns to watch. This is the first appearance of any SDD-specific tool on the Radar. [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit) + +### Developer Articles and Blog Posts + +April produced 12 substantive external articles (plus one excluded as AI-generated SEO spam). + +**Matt Rickard** published *"The Spec Layer: Why Spec-Driven Development (SDD) Works"* on April 1. His thesis: specs reduce execution freedom for AI agents, functioning as constraint surfaces. He compared Spec Kit, Kiro, OpenSpec, Tessl, Intent, and Symphony, and advocated for **"smaller specs, harder checks, less guessing."** [\[blog.matt-rickard.com\]](https://blog.matt-rickard.com/p/the-spec-layer) + +**Fabián Silva** published *"I Built a Visual Spec-Driven Development Extension for VS Code That Works With Any LLM"* on April 3 on DEV Community. His **Caramelo** VS Code extension adds a visual UI, approval gates, Jira integration, and multi-LLM support on top of Spec Kit's workflow, reading and writing the standard `specs/` directory. [\[dev.to\]](https://dev.to/fabian_silva_/i-built-a-visual-spec-driven-development-extension-for-vs-code-that-works-with-any-llm-36ok) + +**James M** published *"GitHub Spec Kit in 2026: SDD Goes Mainstream"* on April 4, calling the transition "from framework to platform" and highlighting Claude Code native skills, multi-agent support, and the massive ecosystem growth. [\[jamesm.blog\]](https://jamesm.blog/ai/github-spec-kit-2026-update/) + +**Peter Saktor** published a detailed tutorial on DEV Community on April 6: *"GitHub Spec-Kit: From Vibe Coding to Spec-Driven Development,"* walking through a full 7-step SDD workflow refactoring an Azure Container App with 33 tasks across 6 phases. [\[dev.to\]](https://dev.to/petersaktor/github-spec-kit-from-vibe-coding-to-spec-driven-development-1pgd) + +**Codexplorer** published *"Spec Kit: GitHub's Answer to 'The AI Built the Wrong Thing Again'"* on Medium (April 11), framing Spec Kit as flipping the spec-code relationship, with Go code examples covering the seven slash commands. [\[medium.com\]](https://codexplorer.medium.com/spec-kit-githubs-answer-to-the-ai-built-the-wrong-thing-again-22f122f142fb) + +**XB Software** published *"Spec Kit on a Real Project: Implementation Experience in Large Legacy Code"* on April 17 — a field report from applying SDD to legacy systems. A week-long task was completed in half the time. The AI surfaced hidden requirements gaps. They noted API integration weakness, that SDD is overkill for small tasks, and that an experienced reviewer is still essential. [\[xbsoftware.com\]](https://xbsoftware.com/blog/ai-in-legacy-systems-spec-driven-development/) + +**What IT Is** published *"Perspectives in Spec Driven Development"* on April 21, surveying the SDD landscape (Spec Kit, Kiro, Tessl) and calling Spec Kit "a good entry point." [\[theitsolutionist.com\]](https://theitsolutionist.com/2026/04/21/perspectives-in-spec-driven-development/) + +**Will Torber** published *"Spec Kit vs BMAD vs OpenSpec: Choosing an SDD Framework in 2026"* on DEV Community on April 23. He recommended Spec Kit for greenfield but flagged brownfield friction and the branch-per-spec limitation, ultimately **recommending OpenSpec for most teams**. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j) + +**Truong Phung** published *"Spec Kit vs. Superpowers: A Comprehensive Comparison & Practical Guide to Combining Both"* on DEV Community on April 25 — an 11-section comparison proposing a hybrid workflow: "Spec Kit plans WHAT, Superpowers controls HOW," with a step-by-step playbook. [\[dev.to\]](https://dev.to/truongpx396/spec-kit-vs-superpowers-a-comprehensive-comparison-practical-guide-to-combining-both-52jj) + +**Markus Wondrak** published *"Re-evaluating GitHub's Spec Kit: Structured SDLC Automation"* on LinkedIn on April 26, examining Spec Kit as a structured SDLC automation approach requiring human review at phase boundaries. [\[linkedin.com\]](https://www.linkedin.com/pulse/re-evaluating-githubs-spec-kit-structured-sdlc-markus-wondrak-eewqf/) + +**FintechExtra** published a factual release-notes summary of v0.8.2 on April 28, highlighting authenticated catalog downloads, the UTF-8 manifest fix, and the Chroma DB semantic search in the fiction writing preset. [\[fintechextra.com\]](https://www.fintechextra.com/news/github-spec-kit-v082-expands-catalog-support-and-tightens-cli-behavior-331) + +### Community Friends and Tools + +The **SpecKit Companion** VS Code extension was added to the Community Friends section (v0.6.0). A community-maintained plugin for **Claude Code and GitHub Copilot CLI** that installs Spec Kit skills via the plugin marketplace was referenced in the README (v0.7.3). Fabián Silva's **Caramelo** VS Code extension demonstrated a visual UI approach to SDD. [\[github.com\]](https://github.com/github/spec-kit) + +## SDD Ecosystem & Industry Trends + +### The "Spec Layer" Debate + +Matt Rickard's "The Spec Layer" essay established a new framing for SDD: specifications as **constraint surfaces** that reduce execution freedom for AI agents. His comparison of six SDD tools argued for smaller, more focused specs with harder verification checks — a departure from comprehensive specification documents. This framing resonated across the community, with the Thoughtworks Radar entry and multiple comparison articles echoing the tension between spec depth and practical overhead. + +### Competitive Landscape + +**Will Torber's** three-framework comparison (Spec Kit, BMAD, OpenSpec) recommended **OpenSpec for most teams**, citing lower ceremony and better brownfield support. **Truong Phung** proposed combining Spec Kit with **Superpowers** (Jesse Vincent) for a "plan WHAT + control HOW" hybrid. These comparisons reflected a maturing market where practitioners combine tools rather than picking one. + +The **Thoughtworks Radar** placement validated SDD as a category worth tracking but flagged instruction bloat and context rot as open concerns — the same issues the Augment Code comparison raised in March. XB Software's field report confirmed these in practice: SDD adds value for complex legacy work but creates unnecessary overhead for small tasks. + +Spec Kit continued to lead in **GitHub popularity** (92k stars) and **agent breadth** (29 integrations). The market continued to differentiate along several axes: Spec Kit on portability and ecosystem breadth, Intent on living specs and drift detection, BMAD-METHOD on multi-agent orchestration, and OpenSpec on simplicity. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j) [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit) + +## Roadmap + +Areas under discussion or in progress for future development: + +- **Spec lifecycle management** — context rot and spec drift remained the most cited concern across articles (Thoughtworks Radar, XB Software, Will Torber). The marker-based upsert (v0.7.3) addressed context file drift; spec-level drift detection remains an open area. The Reconcile and Archive extensions are community steps toward this. [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit) +- **Workflow customization** — the workflow engine (v0.7.0) and preset composition strategies (v0.8.0) provide the foundation. Community presets for fiction writing, screenwriting, Jira tracking, and architecture governance demonstrate the breadth of possible workflows beyond standard SDD. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **Catalog discovery and distribution** — the integration catalog (v0.7.2) and catalog discovery CLI (v0.8.3) bring `specify` closer to a package-manager experience for extensions, presets, and integrations. Private catalog authentication (v0.8.2) supports enterprise distribution. [\[github.com\]](https://github.com/github/spec-kit/releases) +- **Experience simplification** — the bundled lean preset (v0.6.1), `specify self check` (v0.7.5), and the deprecation of `--ai` in favor of `--integration` (v0.7.1) reflect ongoing work to reduce ceremony and improve the onboarding experience. Multiple external articles (Torber, XB Software) noted SDD overhead as a barrier. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j) +- **Cross-platform and enterprise** — Windows CI (v0.7.1), GITHUB_TOKEN authentication (v0.8.2), Salesforce-specific extensions, and the iSAQB architecture governance preset indicate growing enterprise adoption. [\[github.com\]](https://github.com/github/spec-kit) From 94074064c5b46d469d7a32c514f7a6de20afea13 Mon Sep 17 00:00:00 2001 From: Chris Roberts Date: Fri, 1 May 2026 11:41:28 -0700 Subject: [PATCH 158/184] Add token-analyzer to community catalog (#2433) Extension ID: token-analyzer Version: 0.1.0 Author: Chris Roberts | coderandhiker Description: Captures, analyzes, and compares token consumption across SDD workflows Repository: https://github.com/coderandhiker/spec-kit-token-analyzer Co-authored-by: Chris Roberts --- README.md | 1 + extensions/catalog.community.json | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/README.md b/README.md index 024ff3ee7e..102c55ecb0 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ The following community-contributed extensions are available in [`catalog.commun | Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) | | Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) | | TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) | +| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) | | V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) | | Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) | | Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 96d497d178..a952e8b9cc 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -2495,6 +2495,37 @@ "created_at": "2026-04-25T00:00:00Z", "updated_at": "2026-04-25T00:00:00Z" }, + "token-analyzer": { + "name": "Token Consumption Analyzer", + "id": "token-analyzer", + "description": "Captures, analyzes, and compares token consumption across SDD workflows", + "author": "Chris Roberts | coderandhiker", + "version": "0.1.0", + "download_url": "https://github.com/coderandhiker/spec-kit-token-analyzer/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/coderandhiker/spec-kit-token-analyzer", + "homepage": "https://github.com/coderandhiker/spec-kit-token-analyzer", + "documentation": "https://github.com/coderandhiker/spec-kit-token-analyzer/blob/main/README.md", + "changelog": "https://github.com/coderandhiker/spec-kit-token-analyzer/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0" + }, + "provides": { + "commands": 3, + "hooks": 4 + }, + "tags": [ + "tokens", + "measurement", + "optimization", + "analysis" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-01T00:00:00Z", + "updated_at": "2026-05-01T00:00:00Z" + }, "v-model": { "name": "V-Model Extension Pack", "id": "v-model", From 259494a328e13df32e18b2df31abd421c881c071 Mon Sep 17 00:00:00 2001 From: Nimra Akram Date: Sat, 2 May 2026 02:18:19 +0500 Subject: [PATCH 159/184] fix: honor template overrides for tasks-template (#2278) (#2292) * fix: honor template overrides for tasks-template (#2278) - Add scripts/bash/setup-tasks.sh mirroring setup-plan.sh pattern - Add scripts/powershell/setup-tasks.ps1 mirroring setup-plan.ps1 pattern - Update tasks.md frontmatter to use dedicated setup-tasks scripts - Resolve tasks template via override stack and emit path as TASKS_TEMPLATE in JSON output - Reference resolved TASKS_TEMPLATE path in generate step instead of hardcoded path * fix: remove stray EOF tokens from setup-tasks scripts * fix: improve error messages for unresolved tasks-template * test: update file inventory tests to include setup-tasks scripts * fix: use Console::Error.WriteLine instead of Write-Error in setup-tasks.ps1 * fix: write prerequisite error messages to stderr in setup-tasks.ps1 * fix: validate tasks template is a file and normalize path in setup-tasks.ps1 * fix: improve tasks-template error message to mention full override stack * test: add setup-tasks.sh to TestCopilotSkillsMode file inventory * fix: skip feature-branch validation when feature.json pins FEATURE_DIR * fix: correct override path in tasks-template error messages * test: add integration tests for setup-tasks template resolution and branch validation * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: correct fixture paths and add spec.md prerequisite checks * fix: use correct .registry schema in preset priority test * fix: remove stale aaa-preset block and duplicate comment in preset priority test * fix: align preset directory names with registry IDs in priority test --------- Co-authored-by: Nimraakram22 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/bash/setup-tasks.sh | 96 +++ scripts/powershell/setup-tasks.ps1 | 74 +++ templates/commands/tasks.md | 8 +- .../test_integration_base_markdown.py | 4 +- .../test_integration_base_skills.py | 2 + .../test_integration_base_toml.py | 2 + .../test_integration_base_yaml.py | 2 + .../integrations/test_integration_copilot.py | 3 + .../integrations/test_integration_generic.py | 2 + tests/test_setup_tasks.py | 584 ++++++++++++++++++ 10 files changed, 771 insertions(+), 6 deletions(-) create mode 100644 scripts/bash/setup-tasks.sh create mode 100644 scripts/powershell/setup-tasks.ps1 create mode 100644 tests/test_setup_tasks.py diff --git a/scripts/bash/setup-tasks.sh b/scripts/bash/setup-tasks.sh new file mode 100644 index 0000000000..3f6a40b12d --- /dev/null +++ b/scripts/bash/setup-tasks.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash + +set -e + +# Parse command line arguments +JSON_MODE=false + +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) + echo "Usage: $0 [--json]" + echo " --json Output results in JSON format" + echo " --help Show this help message" + exit 0 + ;; + *) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;; + esac +done + +# Source common functions +SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Get feature paths +_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } +eval "$_paths_output" +unset _paths_output + +# Validate branch +# If feature.json pins an existing feature directory, branch naming is not required. +if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then + check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1 +fi + +if [[ ! -f "$IMPL_PLAN" ]]; then + echo "ERROR: plan.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.plan first to create the implementation plan." >&2 + exit 1 +fi + +if [[ ! -f "$FEATURE_SPEC" ]]; then + echo "ERROR: spec.md not found in $FEATURE_DIR" >&2 + echo "Run /speckit.specify first to create the feature structure." >&2 + exit 1 +fi + +# Build available docs list +docs=() +[[ -f "$RESEARCH" ]] && docs+=("research.md") +[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md") +if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then + docs+=("contracts/") +fi +[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md") + +# Resolve tasks template through override stack +TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true +if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then + echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2 + echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2 + exit 1 +fi + +# Output results +if $JSON_MODE; then + if has_jq; then + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .) + fi + jq -cn \ + --arg feature_dir "$FEATURE_DIR" \ + --argjson docs "$json_docs" \ + --arg tasks_template "${TASKS_TEMPLATE:-}" \ + '{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}' + else + if [[ ${#docs[@]} -eq 0 ]]; then + json_docs="[]" + else + json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done) + json_docs="[${json_docs%,}]" + fi + printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \ + "$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" + fi +else + echo "FEATURE_DIR: $FEATURE_DIR" + echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}" + echo "AVAILABLE_DOCS:" + check_file "$RESEARCH" "research.md" + check_file "$DATA_MODEL" "data-model.md" + check_dir "$CONTRACTS_DIR" "contracts/" + check_file "$QUICKSTART" "quickstart.md" +fi diff --git a/scripts/powershell/setup-tasks.ps1 b/scripts/powershell/setup-tasks.ps1 new file mode 100644 index 0000000000..e00ae7a02f --- /dev/null +++ b/scripts/powershell/setup-tasks.ps1 @@ -0,0 +1,74 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [switch]$Json, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +if ($Help) { + Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]" + exit 0 +} + +# Source common functions +. "$PSScriptRoot/common.ps1" + +# Get feature paths and validate branch +$paths = Get-FeaturePathsEnv + +# If feature.json pins an existing feature directory, branch naming is not required. +if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) { + if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) { + exit 1 + } +} + +if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)") + [Console]::Error.WriteLine("Run /speckit.plan first to create the implementation plan.") + exit 1 +} + +if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) { + [Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)") + [Console]::Error.WriteLine("Run /speckit.specify first to create the feature structure.") + exit 1 +} + +# Build available docs list +$docs = @() +if (Test-Path $paths.RESEARCH) { $docs += 'research.md' } +if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' } +if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) { + $docs += 'contracts/' +} +if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' } + +# Resolve tasks template through override stack +$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT +if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) { + $expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md' + [Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.") + exit 1 +} +$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path + +# Output results +if ($Json) { + [PSCustomObject]@{ + FEATURE_DIR = $paths.FEATURE_DIR + AVAILABLE_DOCS = $docs + TASKS_TEMPLATE = $tasksTemplate + } | ConvertTo-Json -Compress +} else { + Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)" + Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })" + Write-Output "AVAILABLE_DOCS:" + Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null + Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null + Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null + Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null +} diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 4e204abc1b..e5af6793b6 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -10,8 +10,8 @@ handoffs: prompt: Start the implementation in phases send: true scripts: - sh: scripts/bash/check-prerequisites.sh --json - ps: scripts/powershell/check-prerequisites.ps1 -Json + sh: scripts/bash/setup-tasks.sh --json + ps: scripts/powershell/setup-tasks.ps1 -Json --- ## User Input @@ -58,7 +58,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). +1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot"). 2. **Load design documents**: Read from FEATURE_DIR: - **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities) @@ -76,7 +76,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Create parallel execution examples per user story - Validate task completeness (each user story has all needed tasks, independently testable) -4. **Generate tasks.md**: Use `templates/tasks-template.md` as structure, fill with: +4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with: - Correct feature name from plan.md - Phase 1: Setup tasks (project initialization) - Phase 2: Foundational tasks (blocking prerequisites for all user stories) diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 82d7b8cfb3..0b74a6f1a9 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -274,11 +274,11 @@ def _expected_files(self, script_variant: str) -> list[str]: if script_variant == "sh": for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", - "setup-plan.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-plan.ps1", "setup-tasks.ps1"]: files.append(f".specify/scripts/powershell/{name}") for name in ["checklist-template.md", diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 98a65fcff4..89140de1c3 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -387,6 +387,7 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/setup-tasks.sh", ] else: files += [ @@ -394,6 +395,7 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/setup-tasks.ps1", ] # Templates files += [ diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 78273b560e..56862e534c 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -516,6 +516,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.sh", "create-new-feature.sh", "setup-plan.sh", + "setup-tasks.sh", ]: files.append(f".specify/scripts/bash/{name}") else: @@ -524,6 +525,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.ps1", "create-new-feature.ps1", "setup-plan.ps1", + "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index e1dee3bad7..956c7a796f 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -395,6 +395,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.sh", "create-new-feature.sh", "setup-plan.sh", + "setup-tasks.sh", ]: files.append(f".specify/scripts/bash/{name}") else: @@ -403,6 +404,7 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.ps1", "create-new-feature.ps1", "setup-plan.ps1", + "setup-tasks.ps1", ]: files.append(f".specify/scripts/powershell/{name}") diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 2df4d2d7df..c6e9259b09 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -206,6 +206,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/setup-tasks.sh", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -265,6 +266,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/setup-tasks.ps1", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -614,6 +616,7 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/setup-tasks.sh", # Templates ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index f0272afa8d..290a36419e 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -264,6 +264,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", + ".specify/scripts/bash/setup-tasks.sh", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -319,6 +320,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", + ".specify/scripts/powershell/setup-tasks.ps1", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", diff --git a/tests/test_setup_tasks.py b/tests/test_setup_tasks.py new file mode 100644 index 0000000000..f2e10d8b0f --- /dev/null +++ b/tests/test_setup_tasks.py @@ -0,0 +1,584 @@ +"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation.""" + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" +SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh" +COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1" +SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1" +TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md" + +HAS_PWSH = shutil.which("pwsh") is not None +_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _install_bash_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "bash" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_SH, d / "common.sh") + shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh") + + +def _install_ps_scripts(repo: Path) -> None: + d = repo / ".specify" / "scripts" / "powershell" + d.mkdir(parents=True, exist_ok=True) + shutil.copy(COMMON_PS, d / "common.ps1") + shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1") + + +def _install_core_tasks_template(repo: Path) -> None: + """Copy the real tasks-template.md into the core template location.""" + tdir = repo / ".specify" / "templates" + tdir.mkdir(parents=True, exist_ok=True) + shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md") + + +def _minimal_feature(repo: Path) -> Path: + """ + Create a numbered branch-style feature directory with spec.md and plan.md + so all prerequisite checks in setup-tasks pass. + Returns the feature directory path. + """ + feat = repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + return feat + + +def _clean_env() -> dict[str, str]: + """ + Return os.environ with all SPECIFY_* variables stripped so the scripts + rely purely on git branch + feature.json state set up by each fixture. + """ + env = os.environ.copy() + for key in list(env): + if key.startswith("SPECIFY_"): + env.pop(key) + return env + + +def _git_init(repo: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=repo, check=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True + ) + + +# --------------------------------------------------------------------------- +# Shared fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def tasks_repo(tmp_path: Path) -> Path: + """ + A minimal repo with: + - git initialised on a numbered branch (001-my-feature) + - core tasks-template.md in place + - both bash and PowerShell scripts installed + """ + repo = tmp_path / "proj" + repo.mkdir() + _git_init(repo) + + # Switch to a numbered branch so branch validation passes without feature.json + subprocess.run( + ["git", "checkout", "-q", "-b", "001-my-feature"], + cwd=repo, + check=True, + ) + + (repo / ".specify").mkdir() + _install_core_tasks_template(repo) + _install_bash_scripts(repo) + _install_ps_scripts(repo) + return repo + + +# =========================================================================== +# BASH TESTS +# =========================================================================== + +@requires_bash +def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None: + """ + When the core tasks-template.md is present and all prerequisites are met, + setup-tasks.sh --json should exit 0 and return an absolute, existing + TASKS_TEMPLATE path pointing to the core template. + """ + feat = _minimal_feature(tasks_repo) + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl.name == "tasks-template.md" + + +@requires_bash +def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None: + """ + When an override exists at .specify/templates/overrides/tasks-template.md, + setup-tasks.sh --json must return the override path, not the core path. + """ + feat = _minimal_feature(tasks_repo) + + # Create the override + overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + override_file = overrides_dir / "tasks-template.md" + override_file.write_text("# override tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + # The resolved path must be inside the overrides directory + assert "overrides" in tasks_tmpl.parts, ( + f"Expected override path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None: + """ + When an extension template exists, setup-tasks.sh --json must resolve + tasks-template.md from the extension before falling back to the core path. + """ + feat = _minimal_feature(tasks_repo) + + # FIX: real extension layout is .specify/extensions//templates/.md + extension_dir = ( + tasks_repo / ".specify" / "extensions" / "test-extension" / "templates" + ) + extension_dir.mkdir(parents=True, exist_ok=True) + extension_file = extension_dir / "tasks-template.md" + extension_file.write_text("# extension tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl == extension_file.resolve(), ( + f"Expected extension path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None: + """ + When both preset and extension templates exist, setup-tasks.sh --json must + resolve the preset path because presets outrank extensions. + """ + feat = _minimal_feature(tasks_repo) + + # FIX: real extension layout is .specify/extensions//templates/.md + extension_dir = ( + tasks_repo / ".specify" / "extensions" / "test-extension" / "templates" + ) + extension_dir.mkdir(parents=True, exist_ok=True) + extension_file = extension_dir / "tasks-template.md" + extension_file.write_text("# extension tasks template\n", encoding="utf-8") + + # FIX: real preset layout is .specify/presets//templates/.md + preset_dir = tasks_repo / ".specify" / "presets" / "test-preset" / "templates" + preset_dir.mkdir(parents=True, exist_ok=True) + preset_file = preset_dir / "tasks-template.md" + preset_file.write_text("# preset tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl == preset_file.resolve(), ( + f"Expected preset path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None: + """ + When two presets both provide tasks-template.md, the one listed first in + .specify/presets/.registry wins. + """ + feat = _minimal_feature(tasks_repo) + + # resolve_template reads .specify/presets/.registry as a JSON object with a + # "presets" map where each entry has a numeric "priority" (lower = higher + # precedence). Create two presets; priority-1-preset wins over priority-2-preset. + high_priority_dir = ( + tasks_repo / ".specify" / "presets" / "priority-1-preset" / "templates" + ) + high_priority_dir.mkdir(parents=True, exist_ok=True) + high_priority_file = high_priority_dir / "tasks-template.md" + high_priority_file.write_text("# high priority preset tasks template\n", encoding="utf-8") + low_priority_dir = ( + tasks_repo / ".specify" / "presets" / "priority-2-preset" / "templates" + ) + + low_priority_dir.mkdir(parents=True, exist_ok=True) + low_priority_file = low_priority_dir / "tasks-template.md" + low_priority_file.write_text("# low priority preset tasks template\n", encoding="utf-8") + + # Write .registry JSON using the correct schema: object with "presets" map, + # each preset has a numeric "priority" (lower number = higher precedence). + registry_json = tasks_repo / ".specify" / "presets" / ".registry" + registry_json.write_text( + json.dumps({ + "presets": { + "priority-1-preset": {"priority": 1, "enabled": True}, + "priority-2-preset": {"priority": 2, "enabled": True}, + } + }), + encoding="utf-8", + ) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl == high_priority_file.resolve(), ( + f"Expected high-priority preset path but got: {tasks_tmpl}" + ) + + +@requires_bash +def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None: + """ + When tasks-template.md is absent from all locations, setup-tasks.sh must + exit non-zero and print a helpful ERROR message to stderr. + """ + feat = _minimal_feature(tasks_repo) + + # Remove the core template so no template exists anywhere + core = tasks_repo / ".specify" / "templates" / "tasks-template.md" + core.unlink() + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "ERROR" in result.stderr + assert "tasks-template" in result.stderr + + +@requires_bash +def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch, setup-tasks.sh must succeed when feature.json + pins a valid FEATURE_DIR (branch validation should be skipped). + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + feat = tasks_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + + (tasks_repo / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/001-my-feature"}), + encoding="utf-8", + ) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + +@requires_bash +def test_setup_tasks_bash_fails_custom_branch_without_feature_json( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch with no feature.json, setup-tasks.sh must fail + and report that we are not on a feature branch. + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh" + + result = subprocess.run( + ["bash", str(script), "--json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + + +# =========================================================================== +# POWERSHELL TESTS +# =========================================================================== + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None: + """ + When the core tasks-template.md is present and all prerequisites are met, + setup-tasks.ps1 -Json should exit 0 and return an absolute, existing + TASKS_TEMPLATE path. + """ + feat = _minimal_feature(tasks_repo) + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert tasks_tmpl.name == "tasks-template.md" + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None: + """ + When an override exists at .specify/templates/overrides/tasks-template.md, + setup-tasks.ps1 -Json must return the override path, not the core path. + """ + feat = _minimal_feature(tasks_repo) + + overrides_dir = tasks_repo / ".specify" / "templates" / "overrides" + overrides_dir.mkdir(parents=True, exist_ok=True) + override_file = overrides_dir / "tasks-template.md" + override_file.write_text("# override tasks template\n", encoding="utf-8") + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + data = json.loads(result.stdout) + tasks_tmpl = Path(data["TASKS_TEMPLATE"]) + assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path" + assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file" + assert "overrides" in tasks_tmpl.parts, ( + f"Expected override path but got: {tasks_tmpl}" + ) + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None: + """ + When tasks-template.md is absent from all locations, setup-tasks.ps1 must + exit non-zero and write a helpful error to stderr. + """ + feat = _minimal_feature(tasks_repo) + + core = tasks_repo / ".specify" / "templates" / "tasks-template.md" + core.unlink() + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower() + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch, setup-tasks.ps1 must succeed when feature.json + pins a valid FEATURE_DIR (branch validation should be skipped). + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + feat = tasks_repo / "specs" / "001-my-feature" + feat.mkdir(parents=True, exist_ok=True) + (feat / "spec.md").write_text("# spec\n", encoding="utf-8") + (feat / "plan.md").write_text("# plan\n", encoding="utf-8") + + (tasks_repo / ".specify" / "feature.json").write_text( + json.dumps({"feature_directory": "specs/001-my-feature"}), + encoding="utf-8", + ) + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + + +@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available") +def test_setup_tasks_ps_fails_custom_branch_without_feature_json( + tasks_repo: Path, +) -> None: + """ + On a non-standard branch with no feature.json, setup-tasks.ps1 must fail + and report that we are not on a feature branch. + """ + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/custom-branch"], + cwd=tasks_repo, + check=True, + ) + + script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1" + exe = "pwsh" if HAS_PWSH else _POWERSHELL + + result = subprocess.run( + [exe, "-NoProfile", "-File", str(script), "-Json"], + cwd=tasks_repo, + capture_output=True, + text=True, + check=False, + env=_clean_env(), + ) + + assert result.returncode != 0 + assert "Not on a feature branch" in result.stderr + \ No newline at end of file From 521b0d9ef76f01b466508d76bdd86ce8a43ee48f Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Mon, 4 May 2026 22:07:58 +0700 Subject: [PATCH 160/184] update security-review and memory-md extensions to latest versions (#2445) * chore: update security-review extension to v1.4.2 * chore: update memory-md description and catalog updated_at --- README.md | 2 +- extensions/catalog.community.json | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 102c55ecb0..801e962549 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ The following community-contributed extensions are available in [`catalog.commun | MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) | | MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) | | Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) | -| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) | +| Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) | | MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | | Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index a952e8b9cc..c8361286cf 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-05-01T15:01:47Z", + "updated_at": "2026-05-03T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1278,20 +1278,20 @@ "memory-md": { "name": "Memory MD", "id": "memory-md", - "description": "Repository-native durable memory for Spec Kit projects", + "description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context", "author": "DyanGalih", - "version": "0.6.9", - "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.9.zip", + "version": "0.7.5", + "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.5.zip", "repository": "https://github.com/DyanGalih/spec-kit-memory-hub", "homepage": "https://github.com/DyanGalih/spec-kit-memory-hub", "documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md", - "changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/docs/memory-workflow-v0.6.md", + "changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/CHANGELOG.md", "license": "MIT", "requires": { - "speckit_version": ">=0.6.0" + "speckit_version": ">=0.2.0" }, "provides": { - "commands": 5, + "commands": 6, "hooks": 0 }, "tags": [ @@ -1299,13 +1299,14 @@ "workflow", "docs", "copilot", - "markdown" + "markdown", + "ai-context" ], "verified": false, "downloads": 0, "stars": 0, "created_at": "2026-04-23T00:00:00Z", - "updated_at": "2026-05-01T14:48:00Z" + "updated_at": "2026-05-03T00:00:00Z" }, "memorylint": { "name": "MemoryLint", @@ -1931,8 +1932,8 @@ "id": "security-review", "description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews", "author": "DyanGalih", - "version": "1.3.3", - "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.3.3.zip", + "version": "1.4.2", + "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.2.zip", "repository": "https://github.com/DyanGalih/spec-kit-security-review", "homepage": "https://github.com/DyanGalih/spec-kit-security-review", "documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md", @@ -1942,7 +1943,7 @@ "speckit_version": ">=0.1.0" }, "provides": { - "commands": 6, + "commands": 7, "hooks": 0 }, "tags": [ @@ -1956,7 +1957,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-03T03:24:03Z", - "updated_at": "2026-05-01T14:48:00Z" + "updated_at": "2026-05-03T00:00:00Z" }, "sf": { "name": "SFSpeckit — Salesforce Spec-Driven Development", From 05d9aa3e90f1e9c6afd7b7a35b01021abb4dabba Mon Sep 17 00:00:00 2001 From: Alex Vieira Date: Mon, 4 May 2026 17:35:18 +0100 Subject: [PATCH 161/184] feat(presets): add Spec2Cloud preset for Azure deployment workflow (#2413) * feat(presets): add Spec2Cloud preset for Azure deployment workflow Co-authored-by: Copilot * feat(presets): add Spec2Cloud preset details to community catalog * fix(presets): update Spec2Cloud URL to point to the correct GitHub repository * feat(presets): update Spec2Cloud entry with created_at and updated_at timestamps * feat(presets): update Spec2Cloud version to 1.1.0 and adjust timestamps * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: update spec2cloud preset details and resolve merge conflicts * fix: reorder Spec2Cloud entry in community presets for consistency --------- Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/community/presets.md | 1 + presets/catalog.community.json | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/community/presets.md b/docs/community/presets.md index 15f2b7c9ff..b1eaffd318 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -21,6 +21,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) | | Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) | | Security Governance | Adds secure development governance: memory-safe-language preference, secure code generation, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/VEX/SLSA, OpenSSF Scorecard, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) | +| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) | | Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) | | VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 8064bfc960..7791365fef 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-05-05T10:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json", "presets": { "a11y-governance": { @@ -401,6 +401,33 @@ "created_at": "2026-04-27T00:00:00Z", "updated_at": "2026-04-27T00:00:00Z" }, + "spec2cloud": { + "name": "Spec2Cloud", + "id": "spec2cloud", + "version": "1.1.0", + "description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.", + "author": "Azure Samples", + "repository": "https://github.com/Azure-Samples/Spec2Cloud", + "download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/preset.zip", + "homepage": "https://aka.ms/spec2cloud", + "documentation": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "templates": 5, + "commands": 8 + }, + "tags": [ + "azure", + "spec2cloud", + "workflow", + "deployment" + ], + "created_at": "2026-04-30T00:00:00Z", + "updated_at": "2026-04-30T00:00:00Z" + }, "toc-navigation": { "name": "Table of Contents Navigation", "id": "toc-navigation", From f47c2eb468080bbda5638983d668b02731ac8a6c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 4 May 2026 11:39:08 -0500 Subject: [PATCH 162/184] chore: release 0.8.5, begin 0.8.6.dev0 development (#2447) * chore: bump version to 0.8.5 * chore: begin 0.8.6.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48db19ddf4..b15c06dbe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.8.5] - 2026-05-04 + +### Changed + +- feat(presets): add Spec2Cloud preset for Azure deployment workflow (#2413) +- update security-review and memory-md extensions to latest versions (#2445) +- fix: honor template overrides for tasks-template (#2278) (#2292) +- Add token-analyzer to community catalog (#2433) +- docs: add April 2026 newsletter (#2434) +- feat: emit init-time notice for git extension default change (#2165) (#2432) +- Update DyanGalih(Memory Hub and Security Review) community extensions (#2429) +- Support controlled multi-install for safe AI agent integrations (#2389) +- chore(integrations): clean up docs and project guard (#2428) +- chore: release 0.8.4, begin 0.8.5.dev0 development (#2431) + ## [0.8.4] - 2026-05-01 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 98920d8549..dd2e597e95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.8.5.dev0" +version = "0.8.6.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From 1994bd766ea2a3b1d9d87dcec18abc9410f39834 Mon Sep 17 00:00:00 2001 From: Thorsten Hindermann Date: Mon, 4 May 2026 18:43:42 +0200 Subject: [PATCH 163/184] Add agent-parity-governance to community catalog (#2382) --- docs/community/presets.md | 1 + presets/catalog.community.json | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/community/presets.md b/docs/community/presets.md index b1eaffd318..e622875ef2 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -8,6 +8,7 @@ The following community-contributed presets customize how Spec Kit behaves — o | 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) | +| 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) | | 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) | | 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) | diff --git a/presets/catalog.community.json b/presets/catalog.community.json index 7791365fef..bf9725e625 100644 --- a/presets/catalog.community.json +++ b/presets/catalog.community.json @@ -31,6 +31,34 @@ "created_at": "2026-04-27T00:00:00Z", "updated_at": "2026-04-27T00:00:00Z" }, + "agent-parity-governance": { + "name": "Agent Parity Governance", + "id": "agent-parity-governance", + "version": "0.1.0", + "description": "Keeps shared AI-agent guidance aligned across a project-defined set of agent instruction surfaces.", + "author": "Thorsten Hindermann", + "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", + "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", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "templates": 6, + "commands": 3 + }, + "tags": [ + "agents", + "governance", + "parity", + "agent-guidance", + "multi-agent" + ], + "created_at": "2026-04-27T00:00:00Z", + "updated_at": "2026-04-27T00:00:00Z" + }, "aide-in-place": { "name": "AIDE In-Place Migration", "id": "aide-in-place", From a7201c183e9cd628ff990dbb85dfbb7250662cda Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Mon, 4 May 2026 19:00:28 +0200 Subject: [PATCH 164/184] fix(workflows): require project for catalog list (#2436) --- src/specify_cli/__init__.py | 2 +- tests/integrations/test_cli.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index ccd670d20e..176eecc2d4 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -5806,7 +5806,7 @@ def workflow_catalog_list(): """List configured workflow catalog sources.""" from .workflows.catalog import WorkflowCatalog, WorkflowCatalogError - project_root = Path.cwd() + project_root = _require_specify_project() catalog = WorkflowCatalog(project_root) try: diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 7732d57300..b94f9cc9fd 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -1117,6 +1117,7 @@ def test_project_scoped_commands_require_specify_directory(self, tmp_path): ["workflow", "remove", "demo"], ["workflow", "search"], ["workflow", "info", "demo"], + ["workflow", "catalog", "list"], ["workflow", "catalog", "add", "https://example.com/catalog.yml"], ["workflow", "catalog", "remove", "0"], ] From 09f7657f5b880fca0fbbfceb5f3759a3bfd64a7e Mon Sep 17 00:00:00 2001 From: Pascal THUET Date: Mon, 4 May 2026 21:08:07 +0200 Subject: [PATCH 165/184] Pin GitHub Actions by SHA (#2441) --- .github/workflows/codeql.yml | 6 +++--- .github/workflows/docs.yml | 11 +++++------ .github/workflows/lint.yml | 2 +- .github/workflows/release-trigger.yml | 2 +- .github/workflows/release.yml | 3 +-- .github/workflows/stale.yml | 2 +- .github/workflows/test.yml | 8 ++++---- 7 files changed, 16 insertions(+), 18 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 01e0df4a51..1af463c718 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,14 +19,14 @@ jobs: language: [ 'actions', 'python' ] steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6fe87ddce2..9cb48f8f38 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -30,12 +30,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 # Fetch all history for git info - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: '8.x' @@ -48,10 +48,10 @@ jobs: docfx docfx.json - name: Setup Pages - uses: actions/configure-pages@v6 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: 'docs/_site' @@ -66,5 +66,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 - + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8b11ccdfff..3b2ad70bfb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Run markdownlint-cli2 uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23 diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml index a451accfe6..c3728e2363 100644 --- a/.github/workflows/release-trigger.yml +++ b/.github/workflows/release-trigger.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 token: ${{ secrets.RELEASE_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b903cf979..9437bd02e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -86,4 +86,3 @@ jobs: --notes-file release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 076d05336a..919add00f0 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -14,7 +14,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v10 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10 with: # Days of inactivity before an issue or PR becomes stale days-before-stale: 150 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7354dd8e28..f7130aa8d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,13 +13,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.13" @@ -34,13 +34,13 @@ jobs: python-version: ["3.11", "3.12", "3.13"] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: ${{ matrix.python-version }} From 4a8f19cc63450effa4952b9574c255f3671e35d8 Mon Sep 17 00:00:00 2001 From: Ben Lawson Date: Mon, 4 May 2026 16:50:12 -0400 Subject: [PATCH 166/184] Update Ralph Loop to v1.0.2 (#2435) --- README.md | 2 +- extensions/catalog.community.json | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 801e962549..01bdbfbe17 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ The following community-contributed extensions are available in [`catalog.commun | 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) | | 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) | -| Ralph Loop | Autonomous implementation loop using AI agent CLI | `code` | Read+Write | [spec-kit-ralph](https://github.com/Rubiss/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) | | 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) | | Repository Index | Generate index for existing repo for overview, architecture and module level. | `docs` | Read-only | [spec-kit-repoindex](https://github.com/liuyiyu/spec-kit-repoindex) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index c8361286cf..d00643bac5 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-05-03T00:00:00Z", + "updated_at": "2026-05-04T17:02:08Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -1598,12 +1598,12 @@ "id": "ralph", "description": "Autonomous implementation loop using AI agent CLI.", "author": "Rubiss", - "version": "1.0.1", - "download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.1.zip", - "repository": "https://github.com/Rubiss/spec-kit-ralph", - "homepage": "https://github.com/Rubiss/spec-kit-ralph", - "documentation": "https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md", - "changelog": "https://github.com/Rubiss/spec-kit-ralph/blob/main/CHANGELOG.md", + "version": "1.0.2", + "download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.0.2.zip", + "repository": "https://github.com/Rubiss-Projects/spec-kit-ralph", + "homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph", + "documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md", + "changelog": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/CHANGELOG.md", "license": "MIT", "requires": { "speckit_version": ">=0.1.0", @@ -1632,7 +1632,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-03-09T00:00:00Z", - "updated_at": "2026-04-12T19:00:00Z" + "updated_at": "2026-05-04T17:02:08Z" }, "reconcile": { "name": "Reconcile Extension", From 0d8685aa80e2c1e9c18f83f661e23e2c7c64250a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=AE=ED=95=B4=EB=8B=AC=EB=B0=A4?= <5061546+formin@users.noreply.github.com> Date: Tue, 5 May 2026 07:14:31 +0900 Subject: [PATCH 167/184] Add multi-model-review extension to community catalog (#2446) Co-authored-by: formin --- README.md | 1 + extensions/catalog.community.json | 50 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/README.md b/README.md index 01bdbfbe17..f730e10b88 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ The following community-contributed extensions are available in [`catalog.commun | Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) | | MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | | Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) | +| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index d00643bac5..1de30036d3 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1340,6 +1340,56 @@ "created_at": "2026-04-09T00:00:00Z", "updated_at": "2026-04-16T13:10:26Z" }, + "multi-model-review": { + "name": "Multi-Model Review", + "id": "multi-model-review", + "description": "Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review.", + "author": "formin", + "version": "0.1.0", + "download_url": "https://github.com/formin/multi-model-review/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/formin/multi-model-review", + "homepage": "https://github.com/formin/multi-model-review", + "documentation": "https://github.com/formin/multi-model-review/blob/main/README.md", + "changelog": "https://github.com/formin/multi-model-review/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.2.0", + "tools": [ + { + "name": "git", + "required": true + }, + { + "name": "codex", + "required": false + }, + { + "name": "gemini", + "required": false + }, + { + "name": "claude", + "required": false + } + ] + }, + "provides": { + "commands": 4, + "hooks": 0 + }, + "tags": [ + "review", + "workflow", + "multi-model", + "spec-driven-development", + "code" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-04T02:51:52Z", + "updated_at": "2026-05-04T02:51:52Z" + }, "onboard": { "name": "Onboard", "id": "onboard", From 10f63c914d4390410fa8ce6bf917e765f76e4c08 Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Tue, 5 May 2026 22:48:19 +0700 Subject: [PATCH 168/184] Add Architecture Guard to community catalog (#2430) * feat: add Architecture Guard to community catalog - Add architecture-guard v1.4.0 extension entry to catalog - Add entry to README community extensions table - Includes built-in Laravel-specific governance rules * chore: update catalog timestamp to 2026-05-05 * fix: address PR feedback - Add 'governance' category to README legend (used by Architecture Guard) - Update architecture-guard timestamps to 2026-05-05 (submission date) - Align with published extension behavior (Laravel support now built-in) * chore: update Architecture Guard category to process - Changed from 'governance' to 'process' (official category) - Aligns with schema in EXTENSION-PUBLISHING-GUIDE.md - Removed 'governance' from category legend (not an official category) * chore: update timestamps to actual UTC datetime - Top-level updated_at: 2026-05-05T07:26:00Z - Entry created_at/updated_at: 2026-05-05T07:26:00Z - Replaces placeholder 00:00:00Z with actual submission time --- README.md | 1 + extensions/catalog.community.json | 35 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f730e10b88..c6b8945974 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ The following community-contributed extensions are available in [`catalog.commun | Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) | | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | | Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | +| Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | | Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) | | Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 1de30036d3..e8fd66cc50 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-05-04T17:02:08Z", + "updated_at": "2026-05-05T07:26:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -100,6 +100,39 @@ "created_at": "2026-04-14T00:00:00Z", "updated_at": "2026-04-14T00:00:00Z" }, + "architecture-guard": { + "name": "Architecture Guard", + "id": "architecture-guard", + "description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.", + "author": "DyanGalih", + "version": "1.4.0", + "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.4.0.zip", + "repository": "https://github.com/DyanGalih/spec-kit-architecture-guard", + "homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard", + "documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md", + "changelog": "https://github.com/DyanGalih/spec-kit-architecture-guard/releases", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0" + }, + "provides": { + "commands": 6, + "hooks": 0 + }, + "tags": [ + "architecture", + "governance", + "drift-detection", + "refactor", + "monolithic", + "microservices" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-05T07:26:00Z", + "updated_at": "2026-05-05T07:26:00Z" + }, "archive": { "name": "Archive Extension", "id": "archive", From 30e6fa9e32e65bc4c7f76a61a0c9feca2f1d3f33 Mon Sep 17 00:00:00 2001 From: Ayesha Aziz <163914368+ayesha-aziz123@users.noreply.github.com> Date: Tue, 5 May 2026 23:28:29 +0500 Subject: [PATCH 169/184] fix: validate URL scheme in build_github_request (#2449) * fix: validate URL scheme in build_github_request * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test: add missing hostname validation test for build_github_request * fix: update docstring and fix import grouping per Copilot feedback * fix: sort imports and simplify url validation in build_github_request --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/specify_cli/_github_http.py | 17 ++++++- tests/test_github_http.py | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/test_github_http.py diff --git a/src/specify_cli/_github_http.py b/src/specify_cli/_github_http.py index ee68a8325c..ffa804dbb7 100644 --- a/src/specify_cli/_github_http.py +++ b/src/specify_cli/_github_http.py @@ -8,8 +8,8 @@ import os import urllib.request -from urllib.parse import urlparse from typing import Dict +from urllib.parse import urlparse # GitHub-owned hostnames that should receive the Authorization header. # Includes codeload.github.com because GitHub archive URL downloads @@ -30,12 +30,25 @@ def build_github_request(url: str) -> urllib.request.Request: ``Authorization: Bearer `` header when the target hostname is one of the known GitHub-owned domains. Non-GitHub URLs are returned as plain requests so credentials are never leaked to third-party hosts. + + Raises: + ValueError: If ``url`` is empty or whitespace-only. + ValueError: If ``url`` does not use the ``http`` or ``https`` scheme. + ValueError: If ``url`` does not include a hostname. """ headers: Dict[str, str] = {} + url = url.strip() + if not url: + raise ValueError("url must not be empty") + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"}: + raise ValueError(f"url must start with http:// or https://, got: {url!r}") + if not parsed.hostname: + raise ValueError(f"url must include a hostname, got: {url!r}") github_token = (os.environ.get("GITHUB_TOKEN") or "").strip() gh_token = (os.environ.get("GH_TOKEN") or "").strip() token = github_token or gh_token or None - hostname = (urlparse(url).hostname or "").lower() + hostname = parsed.hostname.lower() if token and hostname in GITHUB_HOSTS: headers["Authorization"] = f"Bearer {token}" return urllib.request.Request(url, headers=headers) diff --git a/tests/test_github_http.py b/tests/test_github_http.py new file mode 100644 index 0000000000..f414aeeb2b --- /dev/null +++ b/tests/test_github_http.py @@ -0,0 +1,79 @@ +"""Tests for GitHub-authenticated HTTP request helpers.""" + +import os +from unittest.mock import patch + +import pytest + +from specify_cli._github_http import ( + build_github_request, +) + + +class TestBuildGitHubRequest: + """Tests for build_github_request() URL validation and auth handling.""" + + # --- URL Validation Tests --- + + def test_empty_url_raises_value_error(self): + """build_github_request() must reject an empty string URL.""" + with pytest.raises(ValueError, match="url must not be empty"): + build_github_request("") + + def test_whitespace_url_raises_value_error(self): + """build_github_request() must reject a whitespace-only URL.""" + with pytest.raises(ValueError, match="url must not be empty"): + build_github_request(" ") + + def test_non_http_url_raises_value_error(self): + """build_github_request() must reject URLs without http/https scheme.""" + with pytest.raises(ValueError, match="url must start with http"): + build_github_request("not-a-url") + + def test_ftp_url_raises_value_error(self): + """build_github_request() must reject ftp:// URLs.""" + with pytest.raises(ValueError, match="url must start with http"): + build_github_request("ftp://github.com/file.zip") + + # --- Valid URL Tests --- + + def test_valid_https_url_returns_request(self): + """build_github_request() must return a Request for a valid https URL.""" + req = build_github_request("https://github.com/github/spec-kit") + assert req.full_url == "https://github.com/github/spec-kit" + + def test_valid_http_url_returns_request(self): + """build_github_request() must accept http:// URLs.""" + req = build_github_request("http://example.com/file") + assert req.full_url == "http://example.com/file" + + # --- Auth Header Tests --- + + def test_github_token_added_for_github_host(self): + """Authorization header is set when GITHUB_TOKEN is present.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token", "GH_TOKEN": ""}): + req = build_github_request("https://github.com/github/spec-kit") + assert req.get_header("Authorization") == "Bearer test-token" + + def test_gh_token_used_as_fallback(self): + """GH_TOKEN is used when GITHUB_TOKEN is absent.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "fallback-token"}): + req = build_github_request("https://github.com/github/spec-kit") + assert req.get_header("Authorization") == "Bearer fallback-token" + + def test_no_auth_header_for_non_github_host(self): + """Authorization header must NOT be set for non-GitHub URLs.""" + with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}): + req = build_github_request("https://example.com/file") + assert req.get_header("Authorization") is None + + def test_no_auth_header_when_no_token(self): + """No Authorization header when no token is set in environment.""" + with patch.dict(os.environ, {}, clear=True): + req = build_github_request("https://github.com/github/spec-kit") + assert req.get_header("Authorization") is None + + def test_missing_hostname_raises_value_error(self): + """build_github_request() must reject URLs with valid scheme but no hostname.""" + with pytest.raises(ValueError, match="url must include a hostname"): + build_github_request("http://") \ No newline at end of file From 0f2655181400defdac6904a9461f58a7416d4d72 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 5 May 2026 16:59:25 -0500 Subject: [PATCH 170/184] feat: improve catalog submission templates and CODEOWNERS (#2401) Simplify the community catalog submission flow to use issue templates with manual maintainer review (no automation scripts or workflows). - Add explicit CODEOWNERS entries for catalog.community.json files so submissions are automatically assigned to a maintainer for review - Improve preset submission template: - Add 'Required Extensions' optional field - Make 'Templates Provided' optional (supports command-only presets) - Add 'Number of Scripts' optional field The existing extension and preset issue templates already collect all required catalog metadata. Maintainers review submissions and manually update the catalog JSON files. Closes #2400 --- .github/CODEOWNERS | 5 + .github/ISSUE_TEMPLATE/preset_submission.yml | 22 +- .github/workflows/catalog-assign.yml | 59 ++++ README.md | 2 +- docs/community/presets.md | 2 +- extensions/EXTENSION-DEVELOPMENT-GUIDE.md | 8 +- extensions/EXTENSION-PUBLISHING-GUIDE.md | 266 +++---------------- extensions/README.md | 12 +- presets/README.md | 2 +- 9 files changed, 129 insertions(+), 249 deletions(-) create mode 100644 .github/workflows/catalog-assign.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a60b7a0306..cf0686db1a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,8 @@ # Global code owner * @mnriem +# Community catalog files — explicit ownership for when global ownership expands +/extensions/catalog.community.json @mnriem +/integrations/catalog.community.json @mnriem +/presets/catalog.community.json @mnriem + diff --git a/.github/ISSUE_TEMPLATE/preset_submission.yml b/.github/ISSUE_TEMPLATE/preset_submission.yml index 3a1b963492..f80e9cbdc5 100644 --- a/.github/ISSUE_TEMPLATE/preset_submission.yml +++ b/.github/ISSUE_TEMPLATE/preset_submission.yml @@ -95,11 +95,18 @@ body: validations: required: true + - type: input + id: required-extensions + attributes: + label: Required Extensions (optional) + description: Comma-separated list of required extension IDs (e.g., aide) + placeholder: "e.g., aide, canon" + - type: textarea id: templates-provided attributes: label: Templates Provided - description: List the template overrides your preset provides + description: List the template overrides your preset provides (enter "None" if command-only) placeholder: | - spec-template.md — adds compliance section - plan-template.md — includes audit checkpoints @@ -110,10 +117,19 @@ body: - type: textarea id: commands-provided attributes: - label: Commands Provided (optional) - description: List any command overrides your preset provides + label: Commands Provided + description: List the command overrides your preset provides (enter "None" if template-only) placeholder: | - speckit.specify.md — customized for compliance workflows + validations: + required: true + + - type: input + id: scripts-count + attributes: + label: Number of Scripts (optional) + description: How many scripts does your preset provide? (leave empty if none) + placeholder: "e.g., 1" - type: textarea id: tags diff --git a/.github/workflows/catalog-assign.yml b/.github/workflows/catalog-assign.yml new file mode 100644 index 0000000000..4191bcc554 --- /dev/null +++ b/.github/workflows/catalog-assign.yml @@ -0,0 +1,59 @@ +name: "Catalog: Auto-assign submission" + +on: + issues: + types: [opened, labeled] + +jobs: + assign: + if: > + (github.event.action == 'opened' && ( + contains(github.event.issue.labels.*.name, 'extension-submission') || + contains(github.event.issue.labels.*.name, 'preset-submission') + )) || + (github.event.action == 'labeled' && ( + github.event.label.name == 'extension-submission' || + github.event.label.name == 'preset-submission' + )) + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const assigned = (issue.assignees || []).map(a => a.login); + const marker = ''; + + // Assign mnriem if not already assigned + if (!assigned.includes('mnriem')) { + try { + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + assignees: ['mnriem'], + }); + } catch (e) { + console.log(`Warning: could not assign mnriem: ${e.message}`); + } + } + + // Post team notification if not already posted + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + } + ); + if (!comments.some(c => c.body && c.body.includes(marker))) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: marker + '\ncc @github/spec-kit-maintainers — new catalog submission for review.', + }); + } diff --git a/README.md b/README.md index c6b8945974..2ccc5d3fc0 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.c ## 🧩 Community Extensions > [!NOTE] -> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion. +> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion. 🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).** diff --git a/docs/community/presets.md b/docs/community/presets.md index e622875ef2..6eb8019872 100644 --- a/docs/community/presets.md +++ b/docs/community/presets.md @@ -1,7 +1,7 @@ # Community Presets > [!NOTE] -> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. +> Community presets are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. The following community-contributed presets customize how Spec Kit behaves — overriding templates, commands, and terminology without changing any tooling. Presets are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/presets/catalog.community.json): diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md index 42ce2d71df..5f24e71f0c 100644 --- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md +++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md @@ -528,11 +528,9 @@ specify extension add --from https://github.com/.../spec-kit-my Submit to the community catalog for public discovery: -1. **Fork** spec-kit repository -2. **Add entry** to `extensions/catalog.community.json` -3. **Update** the Community Extensions table in `README.md` with your extension -4. **Create PR** following the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) -5. **After merge**, your extension becomes available: +1. **Create a GitHub release** for your extension +2. **File an issue** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template +3. **After review**, a maintainer updates the catalog and your extension becomes available: - Users can browse `catalog.community.json` to discover your extension - Users copy the entry to their own `catalog.json` - Users install with: `specify extension add my-ext` (from their catalog) diff --git a/extensions/EXTENSION-PUBLISHING-GUIDE.md b/extensions/EXTENSION-PUBLISHING-GUIDE.md index 1433738743..be5b375241 100644 --- a/extensions/EXTENSION-PUBLISHING-GUIDE.md +++ b/extensions/EXTENSION-PUBLISHING-GUIDE.md @@ -7,9 +7,8 @@ This guide explains how to publish your extension to the Spec Kit extension cata 1. [Prerequisites](#prerequisites) 2. [Prepare Your Extension](#prepare-your-extension) 3. [Submit to Catalog](#submit-to-catalog) -4. [Verification Process](#verification-process) -5. [Release Workflow](#release-workflow) -6. [Best Practices](#best-practices) +4. [Release Workflow](#release-workflow) +5. [Best Practices](#best-practices) --- @@ -133,222 +132,46 @@ specify extension add --from https://github.com/your-org/spec-k Spec Kit uses a dual-catalog system. For details about how catalogs work, see the main [Extensions README](README.md#extension-catalogs). -**For extension publishing**: All community extensions should be added to `catalog.community.json`. Users browse this catalog and copy extensions they trust into their own `catalog.json`. +**For extension publishing**: All community extensions are listed in `extensions/catalog.community.json`. Users browse this catalog and copy extensions they trust into their own `catalog.json`. -### 1. Fork the spec-kit Repository +### How to Submit -```bash -# Fork on GitHub -# https://github.com/github/spec-kit/fork - -# Clone your fork -git clone https://github.com/YOUR-USERNAME/spec-kit.git -cd spec-kit -``` - -### 2. Add Extension to Community Catalog - -Edit `extensions/catalog.community.json` and add your extension: - -```json -{ - "schema_version": "1.0", - "updated_at": "2026-01-28T15:54:00Z", - "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", - "extensions": { - "your-extension": { - "name": "Your Extension Name", - "id": "your-extension", - "description": "Brief description of your extension", - "author": "Your Name", - "version": "1.0.0", - "download_url": "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.0.0.zip", - "repository": "https://github.com/your-org/spec-kit-your-extension", - "homepage": "https://github.com/your-org/spec-kit-your-extension", - "documentation": "https://github.com/your-org/spec-kit-your-extension/blob/main/docs/", - "changelog": "https://github.com/your-org/spec-kit-your-extension/blob/main/CHANGELOG.md", - "license": "MIT", - "requires": { - "speckit_version": ">=0.1.0", - "tools": [ - { - "name": "required-mcp-tool", - "version": ">=1.0.0", - "required": true - } - ] - }, - "provides": { - "commands": 3, - "hooks": 1 - }, - "tags": [ - "category", - "tool-name", - "feature" - ], - "verified": false, - "downloads": 0, - "stars": 0, - "created_at": "2026-01-28T00:00:00Z", - "updated_at": "2026-01-28T00:00:00Z" - } - } -} -``` - -**Important**: - -- Set `verified: false` (maintainers will verify) -- Set `downloads: 0` and `stars: 0` (auto-updated later) -- Use current timestamp for `created_at` and `updated_at` -- Update the top-level `updated_at` to current time +To submit your extension to the community catalog, file a new issue using the **[Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml)** template. The template collects all required metadata, including: -### 3. Update Community Extensions Table +- Extension ID, name, and version +- Description, author, and license +- Repository, download URL, and documentation links +- Required Spec Kit version and any tool dependencies +- Number of commands and hooks +- Tags and key features +- Testing confirmation -Add your extension to the Community Extensions table in the project root `README.md`: +> [!IMPORTANT] +> Do **not** open a pull request directly to edit `extensions/catalog.community.json`. All community extension submissions must go through the issue template so a maintainer can review the entry and update the catalog. -```markdown -| Your Extension Name | Brief description of what it does | `` | | [repo-name](https://github.com/your-org/spec-kit-your-extension) | -``` - -**(Table) Category** — pick the one that best fits your extension: - -- `docs` — reads, validates, or generates spec artifacts -- `code` — reviews, validates, or modifies source code -- `process` — orchestrates workflow across phases -- `integration` — syncs with external platforms -- `visibility` — reports on project health or progress - -**Effect** — choose one: - -- Read-only — produces reports without modifying files -- Read+Write — modifies files, creates artifacts, or updates specs - -Insert your extension in alphabetical order in the table. +### What Happens After You Submit -### 4. Submit Pull Request +1. Your issue is automatically labeled and assigned to a maintainer for review +2. A maintainer verifies that the catalog entry is complete and correctly formatted +3. Once approved, the maintainer adds your extension to `extensions/catalog.community.json` and the Community Extensions table in the README +4. Your extension becomes discoverable via `specify extension search` -```bash -# Create a branch -git checkout -b add-your-extension - -# Commit your changes -git add extensions/catalog.community.json README.md -git commit -m "Add your-extension to community catalog - -- Extension ID: your-extension -- Version: 1.0.0 -- Author: Your Name -- Description: Brief description -" +### What Maintainers Check -# Push to your fork -git push origin add-your-extension +- The catalog entry fields are complete and correctly formatted +- The download URL is accessible +- The repository exists and contains an `extension.yml` manifest -# Create Pull Request on GitHub -# https://github.com/github/spec-kit/compare -``` - -**Pull Request Template**: - -```markdown -## Extension Submission - -**Extension Name**: Your Extension Name -**Extension ID**: your-extension -**Version**: 1.0.0 -**Author**: Your Name -**Repository**: https://github.com/your-org/spec-kit-your-extension - -### Description -Brief description of what your extension does. - -### Checklist -- [x] Valid extension.yml manifest -- [x] README.md with installation and usage docs -- [x] LICENSE file included -- [x] GitHub release created (v1.0.0) -- [x] Extension tested on real project -- [x] All commands working -- [x] No security vulnerabilities -- [x] Added to extensions/catalog.community.json -- [x] Added to Community Extensions table in README.md - -### Testing -Tested on: -- macOS 13.0+ with spec-kit 0.1.0 -- Project: [Your test project] - -### Additional Notes -Any additional context or notes for reviewers. -``` +> [!NOTE] +> Maintainers do **not** review, audit, or test the extension code itself. ---- - -## Verification Process - -### What Happens After Submission - -1. **Automated Checks** (if available): - - Manifest validation - - Download URL accessibility - - Repository existence - - License file presence - -2. **Manual Review**: - - Code quality review - - Security audit - - Functionality testing - - Documentation review - -3. **Verification**: - - If approved, `verified: true` is set - - Extension appears in `specify extension search --verified` - -### Verification Criteria - -To be verified, your extension must: - -✅ **Functionality**: - -- Works as described in documentation -- All commands execute without errors -- No breaking changes to user workflows - -✅ **Security**: - -- No known vulnerabilities -- No malicious code -- Safe handling of user data -- Proper validation of inputs - -✅ **Code Quality**: - -- Clean, readable code -- Follows extension best practices -- Proper error handling -- Helpful error messages - -✅ **Documentation**: - -- Clear installation instructions -- Usage examples -- Troubleshooting section -- Accurate description +### Typical Review Timeline -✅ **Maintenance**: +- **Review**: 3-7 business days -- Active repository -- Responsive to issues -- Regular updates -- Semantic versioning followed +### Updating an Existing Extension -### Typical Review Timeline - -- **Automated checks**: Immediate (if implemented) -- **Manual review**: 3-7 business days -- **Verification**: After successful review +To update an extension that is already in the catalog (e.g., for a new version), file a new **[Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml)** issue with the updated version, download URL, and any other changed fields. Mention in the issue that this is an update to an existing entry. --- @@ -385,26 +208,7 @@ When releasing a new version: # Create release on GitHub ``` -4. **Update catalog**: - - ```bash - # Fork spec-kit repo (or update existing fork) - cd spec-kit - - # Update extensions/catalog.json - jq '.extensions["your-extension"].version = "1.1.0"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - jq '.extensions["your-extension"].download_url = "https://github.com/your-org/spec-kit-your-extension/archive/refs/tags/v1.1.0.zip"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - jq '.extensions["your-extension"].updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - jq '.updated_at = "2026-02-15T00:00:00Z"' extensions/catalog.json > tmp.json && mv tmp.json extensions/catalog.json - - # Submit PR - git checkout -b update-your-extension-v1.1.0 - git add extensions/catalog.json - git commit -m "Update your-extension to v1.1.0" - git push origin update-your-extension-v1.1.0 - ``` - -5. **Submit update PR** with changelog in description +4. **File an update submission** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template with the new version and download URL. Mention in the issue that this is an update to an existing entry. --- @@ -473,9 +277,9 @@ A: The main catalog is for public extensions only. For private extensions: - Users add your catalog: `specify extension add-catalog https://your-domain.com/catalog.json` - Not yet implemented - coming in Phase 4 -### Q: How long does verification take? +### Q: How long does review take? -A: Typically 3-7 business days for initial review. Updates to verified extensions are usually faster. +A: Typically 3-7 business days. Updates to existing extensions are usually faster. ### Q: What if my extension is rejected? @@ -483,11 +287,11 @@ A: You'll receive feedback on what needs to be fixed. Make the changes and resub ### Q: Can I update my extension anytime? -A: Yes, submit a PR to update the catalog with your new version. Verified status may be re-evaluated for major changes. +A: Yes, file a new [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) issue with the updated version and download URL. Mention that it is an update to an existing entry. ### Q: Do I need to be verified to be in the catalog? -A: No, unverified extensions are still searchable. Verification just adds trust and visibility. +A: No. All community extensions are listed in the catalog once their submission is reviewed and accepted. ### Q: Can extensions have paid features? @@ -536,7 +340,7 @@ A: Extensions should be free and open-source. Commercial support/services are al "hooks": "integer (optional)" }, "tags": ["array of strings (2-10 tags)"], - "verified": "boolean (default: false)", + "verified": "boolean (default: false, set by maintainers)", "downloads": "integer (auto-updated)", "stars": "integer (auto-updated)", "created_at": "string (ISO 8601 datetime)", diff --git a/extensions/README.md b/extensions/README.md index f535ba539a..4dc9e64f5c 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -25,13 +25,13 @@ specify extension search # Now uses your organization's catalog instead of the ### Community Reference Catalog (`catalog.community.json`) > [!NOTE] -> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion. +> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. Review extension source code before installation and use at your own discretion. - **Purpose**: Browse available community-contributed extensions - **Status**: Active - contains extensions submitted by the community - **Location**: `extensions/catalog.community.json` - **Usage**: Reference catalog for discovering available extensions -- **Submission**: Open to community contributions via Pull Request +- **Submission**: Open to community contributions via [issue template](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) **How It Works:** @@ -72,7 +72,7 @@ specify extension add --from https://github.com/org/spec-kit-ex ## Available Community Extensions > [!NOTE] -> Community extensions are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion. +> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion. 🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).** @@ -89,10 +89,8 @@ To add your extension to the community catalog: 1. **Prepare your extension** following the [Extension Development Guide](EXTENSION-DEVELOPMENT-GUIDE.md) 2. **Create a GitHub release** for your extension -3. **Submit a Pull Request** that: - - Adds your extension to `extensions/catalog.community.json` - - Updates this README with your extension in the Available Extensions table -4. **Wait for review** - maintainers will review and merge if criteria are met +3. **File an issue** using the [Extension Submission](https://github.com/github/spec-kit/issues/new?template=extension_submission.yml) template with all required metadata +4. **Wait for review** — a maintainer will review the submission, update the catalog, and close the issue See the [Extension Publishing Guide](EXTENSION-PUBLISHING-GUIDE.md) for detailed step-by-step instructions. diff --git a/presets/README.md b/presets/README.md index abaeb27067..29cce64248 100644 --- a/presets/README.md +++ b/presets/README.md @@ -98,7 +98,7 @@ Multiple composing presets chain recursively. For example, a security preset wit Presets are discovered through catalogs. By default, Spec Kit uses the official and community catalogs: > [!NOTE] -> Community presets are independently created and maintained by their respective authors. GitHub and the Spec Kit maintainers may review pull requests that add entries to the community catalog for formatting, catalog structure, or policy compliance, but they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. +> Community presets are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted — they do **not review, audit, endorse, or support the preset code itself**. Review preset source code before installation and use at your own discretion. ```bash # List active catalogs From b4060d562040341e1cf35d9ef0d266c52728d72f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 06:56:59 -0500 Subject: [PATCH 171/184] Load constitution context in `/speckit.implement` to enforce governance during implementation (#2460) * Initial plan * fix implement command to load constitution context Agent-Logs-Url: https://github.com/github/spec-kit/sessions/05663d9d-149b-4c13-a22d-2552b3fa619c Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- templates/commands/implement.md | 1 + tests/integrations/test_integration_generic.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/templates/commands/implement.md b/templates/commands/implement.md index 7ba5ba8e0c..52a042161f 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -88,6 +88,7 @@ You **MUST** consider the user input before proceeding (if not empty). - **IF EXISTS**: Read data-model.md for entities and relationships - **IF EXISTS**: Read contracts/ for API specifications and test requirements - **IF EXISTS**: Read research.md for technical decisions and constraints + - **IF EXISTS**: Read /memory/constitution.md for governance constraints - **IF EXISTS**: Read quickstart.md for integration scenarios 4. **Project Setup Verification**: diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 290a36419e..4f515a01d2 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -185,6 +185,16 @@ def test_plan_references_correct_context_file(self, tmp_path): ) assert "__CONTEXT_FILE__" not in content + def test_implement_loads_constitution_context(self, tmp_path): + """The generated implement command should load constitution governance context.""" + i = get_integration("generic") + m = IntegrationManifest("generic", tmp_path) + i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) + implement_file = tmp_path / ".custom" / "cmds" / "speckit.implement.md" + assert implement_file.exists() + content = implement_file.read_text(encoding="utf-8") + assert ".specify/memory/constitution.md" in content + # -- CLI -------------------------------------------------------------- def test_cli_generic_without_commands_dir_fails(self, tmp_path): From 77e605da6bff2f9809461f17d01f34d65f87d2e7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 6 May 2026 07:02:55 -0500 Subject: [PATCH 172/184] chore: release 0.8.6, begin 0.8.7.dev0 development (#2463) * chore: bump version to 0.8.6 * chore: begin 0.8.7.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b15c06dbe2..602b1129d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.8.6] - 2026-05-06 + +### Changed + +- Load constitution context in `/speckit.implement` to enforce governance during implementation (#2460) +- feat: improve catalog submission templates and CODEOWNERS (#2401) +- fix: validate URL scheme in build_github_request (#2449) +- Add Architecture Guard to community catalog (#2430) +- Add multi-model-review extension to community catalog (#2446) +- Update Ralph Loop to v1.0.2 (#2435) +- Pin GitHub Actions by SHA (#2441) +- fix(workflows): require project for catalog list (#2436) +- Add agent-parity-governance to community catalog (#2382) +- chore: release 0.8.5, begin 0.8.6.dev0 development (#2447) + ## [0.8.5] - 2026-05-04 ### Changed diff --git a/pyproject.toml b/pyproject.toml index dd2e597e95..cfeaf74fc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.8.6.dev0" +version = "0.8.7.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From c0bf5d0c648574e49e52f17d6042c178b888a200 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Wed, 6 May 2026 22:07:02 +0500 Subject: [PATCH 173/184] feat(catalog): add Cost Tracker (cost) community extension (#2448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(catalog): add Cost Tracker (cost) community extension Adds a new entry for spec-kit-cost — track real LLM dollar cost across SDD workflows with per-feature budgets, per-integration comparison, and finance-ready exports. Repo: https://github.com/Quratulain-bilal/spec-kit-cost Release: v1.0.0 * docs(catalog): add Cost Tracker README row, bump updated_at Address Copilot review feedback: - Add Cost Tracker row to README community extensions table - Bump top-level updated_at per EXTENSION-PUBLISHING-GUIDE.md * fix(catalog): address Copilot feedback on cost extension entry - Move cost entry after confluence so the c* block is alphabetized - Bump top-level updated_at to 2026-05-05 per EXTENSION-PUBLISHING-GUIDE - Use documented 'visibility' category in README (not 'analytics'), matching Token Consumption Analyzer's classification - Replace 'analytics' tag with 'visibility' in catalog tags for consistency * fix(catalog): bump top-level updated_at for cost entry addition Address Copilot feedback: the file-level updated_at must be bumped on every catalog change per EXTENSION-PUBLISHING-GUIDE.md:204-205. --------- Co-authored-by: Quratulain-bilal --- README.md | 1 + extensions/catalog.community.json | 36 +++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2ccc5d3fc0..a9516af5c3 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,7 @@ The following community-contributed extensions are available in [`catalog.commun | Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) | | Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) | | Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) | +| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) | | DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. Zero NPM runtime dependencies. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) | | Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) | | Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index e8fd66cc50..81d4e1f18e 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-05-05T07:26:00Z", + "updated_at": "2026-05-06T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -580,6 +580,38 @@ "created_at": "2026-03-29T00:00:00Z", "updated_at": "2026-03-29T00:00:00Z" }, + "cost": { + "name": "Cost Tracker", + "id": "cost", + "description": "Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-cost/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-cost", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-cost", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-cost/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-cost/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.8.0" + }, + "provides": { + "commands": 5, + "hooks": 0 + }, + "tags": [ + "cost", + "budget", + "tokens", + "visibility", + "finance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-03T00:00:00Z", + "updated_at": "2026-05-05T00:00:00Z" + }, "diagram": { "name": "Spec Diagram", "id": "diagram", @@ -2905,7 +2937,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-13T00:00:00Z", - "updated_at": "2026-04-13T00:00:00Z" + "updated_at": "2026-04-13T00:00:00Z" } } } From 793632089ad924591c32a5b01a5192d8cc254b2b Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Suazo <97453318+ericnoam@users.noreply.github.com> Date: Wed, 6 May 2026 19:19:10 +0200 Subject: [PATCH 174/184] fix(forge): use hyphen notation for command refs in Forge integration (#2462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(forge): use hyphen notation for command refs in Forge integration - Add invoke_separator = "-" class attribute to ForgeIntegration so effective_invoke_separator() returns "-" for shared-template installs - Add "invoke_separator": "-" to ForgeIntegration.registrar_config so agents.py CommandRegistrar can resolve refs with the correct separator - Pass invoke_separator to process_template() in ForgeIntegration.setup() so all .forge/commands/*.md bodies use /speckit-foo notation - Replace literal /speckit.specify with __SPECKIT_COMMAND_SPECIFY__ in extensions/git/commands/speckit.git.feature.md so every agent resolves the reference through its own separator - Apply resolve_command_refs re.sub in agents.py register_commands() after argument-placeholder substitution so extension commands registered for Forge get /speckit-foo refs; all other agents continue to get /speckit.foo Fixes ZSH compatibility: dot-notation command invocations (/speckit.specify) are misinterpreted by ZSH as file-path operations; hyphen notation (/speckit-specify) works correctly in all shells. * fix(agents): propagate invoke_separator from integration class into AGENT_CONFIGS Skills-based agents (claude, codex, kimi, …) inherit invoke_separator="-" from SkillsIntegration but do not repeat it in their registrar_config dicts. _build_agent_configs() was copying registrar_config verbatim, so register_commands() fell back to "." when resolving __SPECKIT_COMMAND_*__ tokens for those agents — emitting /speckit.specify instead of the correct /speckit-specify for extension commands like speckit.git.feature. Fix: after copying registrar_config, inject invoke_separator from the integration's class attribute when it is not already declared explicitly. This makes the integration class the single source of truth for all agents, without requiring each SkillsIntegration subclass to duplicate the field. Also replace the inline re.sub in register_commands() with a call to IntegrationBase.resolve_command_refs() (deferred import to avoid the existing circular dependency) so token-resolution logic is not duplicated. Adds two tests in test_agent_config_consistency.py: - test_skills_agents_have_hyphen_invoke_separator_in_agent_configs: asserts every /SKILL.md agent has invoke_separator="-" in AGENT_CONFIGS. - test_skills_agent_command_token_resolves_with_hyphen: end-to-end check via CommandRegistrar that the git extension's speckit.git.feature command is installed for Claude with /speckit-specify (not /speckit.specify). Addresses review comment on PR #2462. --- .../git/commands/speckit.git.feature.md | 2 +- src/specify_cli/agents.py | 63 ++++++++++---- .../integrations/forge/__init__.py | 3 + tests/integrations/test_integration_forge.py | 78 +++++++++++++++++ tests/test_agent_config_consistency.py | 86 ++++++++++++++++++- 5 files changed, 215 insertions(+), 17 deletions(-) diff --git a/extensions/git/commands/speckit.git.feature.md b/extensions/git/commands/speckit.git.feature.md index 1a9c5e35da..5bed9e5e57 100644 --- a/extensions/git/commands/speckit.git.feature.md +++ b/extensions/git/commands/speckit.git.feature.md @@ -4,7 +4,7 @@ description: "Create a feature branch with sequential or timestamp numbering" # Create Feature Branch -Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow. +Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `__SPECKIT_COMMAND_SPECIFY__` workflow. ## User Input diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 726b0fd2a6..4d78d5ac41 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -7,12 +7,12 @@ """ import os -from pathlib import Path -from typing import Dict, List, Any, Optional - import platform import re from copy import deepcopy +from pathlib import Path +from typing import Any, Dict, List, Optional + import yaml @@ -25,7 +25,16 @@ def _build_agent_configs() -> dict[str, Any]: if key == "generic": continue if integration.registrar_config: - configs[key] = dict(integration.registrar_config) + config = dict(integration.registrar_config) + # Propagate invoke_separator from the integration class when the + # registrar_config dict doesn't already declare it explicitly. + # SkillsIntegration subclasses (claude, codex, …) set + # invoke_separator="-" as a class attribute but omit it from + # registrar_config, so without this they would fall back to "." + # when register_commands() resolves __SPECKIT_COMMAND_*__ tokens. + if "invoke_separator" not in config: + config["invoke_separator"] = integration.invoke_separator + configs[key] = config return configs @@ -419,9 +428,7 @@ def _ensure_inside(candidate: Path, base: Path) -> None: normalized = Path(os.path.normpath(candidate)) base_normalized = Path(os.path.normpath(base)) 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}") def register_commands( self, @@ -471,7 +478,10 @@ def register_commands( if frontmatter.get("strategy") == "wrap": from .presets import _substitute_core_template - body, core_frontmatter = _substitute_core_template(body, cmd_name, project_root, self) + + body, core_frontmatter = _substitute_core_template( + body, cmd_name, project_root, self + ) frontmatter = dict(frontmatter) for key in ("scripts", "agent_scripts"): if key not in frontmatter and key in core_frontmatter: @@ -492,6 +502,16 @@ def register_commands( body, "$ARGUMENTS", agent_config["args"] ) + # Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator. + # The separator is sourced from agent_config (populated by _build_agent_configs, + # which propagates each integration's invoke_separator class attribute). + # Deferred import of IntegrationBase avoids a circular import at module load + # (base.py itself imports CommandRegistrar lazily). + from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415 + + _sep = agent_config.get("invoke_separator", ".") + body = IntegrationBase.resolve_command_refs(body, _sep) + output_name = self._compute_output_name(agent_name, cmd_name, agent_config) if agent_config["extension"] == "/SKILL.md": @@ -505,12 +525,22 @@ def register_commands( project_root, ) elif agent_config["format"] == "markdown": - body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) - body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"]) - output = self.render_markdown_command(frontmatter, body, source_id, context_note) + body = self.resolve_skill_placeholders( + agent_name, frontmatter, body, project_root + ) + body = self._convert_argument_placeholder( + body, "$ARGUMENTS", agent_config["args"] + ) + output = self.render_markdown_command( + frontmatter, body, source_id, context_note + ) elif agent_config["format"] == "toml": - body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root) - body = self._convert_argument_placeholder(body, "$ARGUMENTS", agent_config["args"]) + body = self.resolve_skill_placeholders( + agent_name, frontmatter, body, project_root + ) + body = self._convert_argument_placeholder( + body, "$ARGUMENTS", agent_config["args"] + ) output = self.render_toml_command(frontmatter, body, source_id) elif agent_config["format"] == "yaml": output = self.render_yaml_command( @@ -685,8 +715,11 @@ def register_commands_for_non_skill_agents( if agent_dir.exists(): try: registered = self.register_commands( - agent_name, commands, source_id, - source_dir, project_root, + agent_name, + commands, + source_id, + source_dir, + project_root, context_note=context_note, ) if registered: diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index a941d4c331..47a90687dc 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -87,8 +87,10 @@ class ForgeIntegration(MarkdownIntegration): "strip_frontmatter_keys": ["handoffs"], "inject_name": True, "format_name": format_forge_command_name, # Custom name formatter + "invoke_separator": "-", } context_file = "AGENTS.md" + invoke_separator = "-" def setup( self, @@ -133,6 +135,7 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, context_file=self.context_file or "", + invoke_separator=self.invoke_separator, ) # FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 8cd8b17c95..62fee73210 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -141,6 +141,7 @@ def test_directory_structure(self, tmp_path): assert actual_commands == expected_commands def test_templates_are_processed(self, tmp_path): + import re from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) @@ -157,6 +158,11 @@ def test_templates_are_processed(self, tmp_path): assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS" # Frontmatter sections should be stripped assert "\nscripts:\n" not in content + # Check Forge-specific: command references use hyphen notation, not dot notation + assert not re.search(r"/speckit\.[a-z]", content), ( + f"{cmd_file.name} contains dot-notation command reference (/speckit.); " + "Forge requires hyphen notation (/speckit-) for ZSH compatibility" + ) def test_plan_references_correct_context_file(self, tmp_path): """The generated plan command must reference forge's context file.""" @@ -224,6 +230,33 @@ def test_uses_parameters_placeholder(self, tmp_path): "checklist should contain {{parameters}} in User Input section" ) + def test_command_refs_use_hyphen_notation(self, tmp_path): + """Verify all generated Forge command files use /speckit-foo, not /speckit.foo.""" + import re + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + commands_dir = tmp_path / ".forge" / "commands" + + files_with_refs = [] + files_with_dot_refs = [] + for cmd_file in commands_dir.glob("speckit.*.md"): + content = cmd_file.read_text(encoding="utf-8") + if re.search(r"/speckit-[a-z]", content): + files_with_refs.append(cmd_file.name) + if re.search(r"/speckit\.[a-z]", content): + files_with_dot_refs.append(cmd_file.name) + + assert files_with_dot_refs == [], ( + f"Files contain dot-notation command references: {files_with_dot_refs}. " + "Forge requires hyphen notation (/speckit-) for ZSH compatibility." + ) + assert len(files_with_refs) > 0, ( + "Expected at least one generated Forge command to contain /speckit- reference, " + "but none were found. Check that __SPECKIT_COMMAND_*__ tokens are being resolved." + ) + def test_name_field_uses_hyphenated_format(self, tmp_path): """Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan).""" from specify_cli.integrations.forge import ForgeIntegration @@ -401,3 +434,48 @@ def test_registrar_does_not_affect_other_agents(self, tmp_path): assert "name:" not in content, ( "Windsurf should not inject name field - format_name callback should be Forge-only" ) + + def test_git_extension_command_uses_hyphen_notation(self, tmp_path): + """Verify the git extension's feature command uses /speckit-specify (not /speckit.specify) for Forge.""" + from pathlib import Path + from specify_cli.agents import CommandRegistrar + + # Locate the real git extension command source file + repo_root = Path(__file__).resolve().parent.parent.parent + ext_dir = repo_root / "extensions" / "git" + cmd_source = ext_dir / "commands" / "speckit.git.feature.md" + assert cmd_source.exists(), ( + f"Git extension command source not found at {cmd_source}. " + "Ensure extensions/git/commands/speckit.git.feature.md exists." + ) + + registrar = CommandRegistrar() + commands = [ + { + "name": "speckit.git.feature", + "file": "commands/speckit.git.feature.md", + } + ] + + registered = registrar.register_commands( + "forge", + commands, + "git", + ext_dir, + tmp_path, + ) + + assert "speckit.git.feature" in registered + + forge_cmd = tmp_path / ".forge" / "commands" / "speckit.git.feature.md" + assert forge_cmd.exists(), "Expected Forge command file was not created" + + content = forge_cmd.read_text(encoding="utf-8") + assert "/speckit-specify" in content, ( + "Expected '/speckit-specify' (hyphen) in generated Forge git.feature command body, " + "but it was not found. Check that __SPECKIT_COMMAND_SPECIFY__ is resolved correctly." + ) + assert "/speckit.specify" not in content, ( + "Found '/speckit.specify' (dot notation) in generated Forge git.feature command body. " + "Forge requires hyphen notation for ZSH compatibility." + ) diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 75e80fdf33..2f0fe15127 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -5,7 +5,6 @@ from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP from specify_cli.extensions import CommandRegistrar - REPO_ROOT = Path(__file__).resolve().parent.parent @@ -199,3 +198,88 @@ def test_goose_in_extension_registrar(self): def test_ai_help_includes_goose(self): """CLI help text for --ai should include goose.""" assert "goose" in AI_ASSISTANT_HELP + + # --- invoke_separator propagation checks --- + + def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self): + """Skills-based agents must expose invoke_separator='-' in AGENT_CONFIGS. + + SkillsIntegration sets ``invoke_separator = "-"`` as a class attribute, + but individual skills integrations (claude, codex, …) do not repeat it in + their ``registrar_config`` dicts. ``_build_agent_configs()`` must + propagate the class attribute so that ``register_commands()`` resolves + ``__SPECKIT_COMMAND_*__`` tokens with the correct hyphen separator. + """ + cfg = CommandRegistrar.AGENT_CONFIGS + skills_agents = [ + key for key, c in cfg.items() if c.get("extension") == "/SKILL.md" + ] + assert skills_agents, ( + "Expected at least one skills-based agent in AGENT_CONFIGS" + ) + for agent in skills_agents: + assert cfg[agent].get("invoke_separator") == "-", ( + f"Skills agent '{agent}' has invoke_separator=" + f"{cfg[agent].get('invoke_separator')!r} in AGENT_CONFIGS; " + "expected '-' (propagated from SkillsIntegration.invoke_separator)" + ) + + def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path): + """__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit- + when registered for a skills-based agent (e.g. claude). + + Regression guard: before the fix, _build_agent_configs() did not + propagate invoke_separator from the integration class, so + register_commands() fell back to '.' and emitted /speckit.specify instead + of /speckit-specify for skills agents. + """ + import re + from pathlib import Path + + from specify_cli.agents import CommandRegistrar + + repo_root = Path(__file__).resolve().parent.parent + ext_dir = repo_root / "extensions" / "git" + cmd_source = ext_dir / "commands" / "speckit.git.feature.md" + assert cmd_source.exists(), ( + f"Git extension command source not found at {cmd_source}" + ) + assert "__SPECKIT_COMMAND_SPECIFY__" in cmd_source.read_text( + encoding="utf-8" + ), ( + "Expected __SPECKIT_COMMAND_SPECIFY__ token in speckit.git.feature.md; " + "check that the file uses the token rather than a hard-coded ref." + ) + + registrar = CommandRegistrar() + commands = [ + {"name": "speckit.git.feature", "file": "commands/speckit.git.feature.md"} + ] + + registered = registrar.register_commands( + "claude", + commands, + "git", + ext_dir, + tmp_path, + ) + + assert "speckit.git.feature" in registered + skill_file = ( + tmp_path / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md" + ) + assert skill_file.exists(), ( + f"Expected Claude skill file not found at {skill_file}" + ) + content = skill_file.read_text(encoding="utf-8") + assert "/speckit-specify" in content, ( + "Expected '/speckit-specify' (hyphen) in generated Claude skill for git.feature; " + "__SPECKIT_COMMAND_SPECIFY__ was not resolved with the correct separator." + ) + # Negative lookbehind (?) in generated Claude skill. " + "Skills agents must use hyphen notation." + ) From 2d5e63005d80d09e219cccd5cbf13526bbe5dc82 Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Wed, 6 May 2026 20:48:50 +0300 Subject: [PATCH 175/184] fix: default non-interactive init to copilot integration (#2414) * fix: default non-interactive init integration * chore: clarify non-interactive init default integration * Address non-interactive init review feedback * Fix interactive init test after fallback --- README.md | 2 +- docs/installation.md | 2 ++ docs/reference/core.md | 2 ++ src/specify_cli/__init__.py | 17 +++++++++++--- tests/integrations/test_cli.py | 23 +++++++++++++++++++ tests/integrations/test_integration_claude.py | 5 +++- 6 files changed, 46 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a9516af5c3..54075ff52e 100644 --- a/README.md +++ b/README.md @@ -485,7 +485,7 @@ specify init --here --force ![Specify CLI bootstrapping a new project in the terminal](./media/specify_cli.gif) -You will be prompted to select the coding agent integration you are using. You can also proactively specify it directly in the terminal: +In an interactive terminal, you will be prompted to select the coding agent integration you are using. In non-interactive sessions, such as CI or piped runs, `specify init` defaults to GitHub Copilot unless you pass `--integration`. You can also proactively specify the integration directly in the terminal: ```bash specify init --integration copilot diff --git a/docs/installation.md b/docs/installation.md index 7f6aa089b7..e53282c0b9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -41,6 +41,8 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init --here ### Specify Integration +Interactive terminals prompt you to choose a coding agent integration during initialization. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot unless you pass `--integration`. + You can proactively specify your coding agent integration during initialization: ```bash diff --git a/docs/reference/core.md b/docs/reference/core.md index aeef06ab79..0b7b5b49bc 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -28,6 +28,8 @@ Creates a new Spec Kit project with the necessary directory structure, templates Use `` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation. +When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration ` to choose a different integration explicitly. + ### Examples ```bash diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 176eecc2d4..f1b1d261c7 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -90,6 +90,7 @@ def _build_agent_config() -> dict[str, dict[str, Any]]: return config AGENT_CONFIG = _build_agent_config() +DEFAULT_INIT_INTEGRATION = "copilot" AI_ASSISTANT_ALIASES = { "kiro": "kiro-cli", @@ -152,6 +153,9 @@ def _build_ai_deprecation_warning( f"Use [bold]{replacement}[/bold] instead." ) +def _stdin_is_interactive() -> bool: + return sys.stdin.isatty() + SCRIPT_TYPE_CHOICES = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" @@ -995,7 +999,8 @@ def init( This command will: 1. Check that required tools are installed (git is optional) - 2. Let you choose your coding agent integration + 2. Let you choose your coding agent integration, or default to Copilot + in non-interactive sessions 3. Download template from GitHub (or use bundled assets with --offline) 4. Initialize a fresh git repository (if not --no-git and no existing repo) 5. Optionally set up coding agent integration commands @@ -1162,13 +1167,19 @@ def init( console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}") raise typer.Exit(1) selected_ai = ai_assistant + elif not _stdin_is_interactive(): + console.print( + f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. " + "Use --integration to choose a different agent.[/dim]" + ) + selected_ai = DEFAULT_INIT_INTEGRATION else: # Create options dict for selection (agent_key: display_name) ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} selected_ai = select_with_arrows( ai_choices, "Choose your coding agent integration:", - "copilot" + DEFAULT_INIT_INTEGRATION, ) # Auto-promote interactively selected agents to the integration path @@ -1233,7 +1244,7 @@ def init( else: default_script = "ps" if os.name == "nt" else "sh" - if sys.stdin.isatty(): + if _stdin_is_interactive(): selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script) else: selected_script = default_script diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index b94f9cc9fd..c04975ce66 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -81,6 +81,29 @@ def test_integration_copilot_creates_files(self, tmp_path): shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" assert shared_manifest.exists() + def test_noninteractive_init_defaults_to_copilot(self, tmp_path, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + import specify_cli + + def fail_select(*_args, **_kwargs): + raise AssertionError("non-interactive init should not open the integration picker") + + monkeypatch.setattr(specify_cli, "select_with_arrows", fail_select) + + runner = CliRunner() + project = tmp_path / "noninteractive" + result = runner.invoke(app, [ + "init", str(project), "--script", "sh", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + + assert result.exit_code == 0, result.output + assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output + assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists() + + data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) + assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION + def test_ai_copilot_auto_promotes(self, tmp_path): from typer.testing import CliRunner from specify_cli import app diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index b3236a66b7..142db0dd92 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -196,7 +196,10 @@ def test_interactive_claude_selection_uses_integration_path(self, tmp_path): try: os.chdir(project) runner = CliRunner() - with patch("specify_cli.select_with_arrows", return_value="claude"): + with ( + patch("specify_cli._stdin_is_interactive", return_value=True), + patch("specify_cli.select_with_arrows", return_value="claude"), + ): result = runner.invoke( app, [ From 0facb1bdc2c33a8c5724c7b683c4565181f32752 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 6 May 2026 13:23:23 -0500 Subject: [PATCH 176/184] Add fx-to-dotnet to community extension catalog (#2471) * Add fx-to-dotnet to community extension catalog - Extension ID: fx-to-dotnet - Version: 0.8.0 - Author: RogerBestMsft - .NET Framework to Modern .NET Migration Closes #2469 * Address review: remove tool version, fix table ordering - Remove meaningless >=0.0.0 version from required tool entry - Move .NET Framework row to correct alphabetical position (after Multi-Model Review) - Lowercase link label to match table conventions --- README.md | 1 + extensions/catalog.community.json | 38 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/README.md b/README.md index 54075ff52e..86126bc6a4 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ The following community-contributed extensions are available in [`catalog.commun | MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) | | Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) | | Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) | +| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) | | Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) | | Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) | | OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 81d4e1f18e..d7503a2a31 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -842,6 +842,44 @@ "created_at": "2026-03-06T00:00:00Z", "updated_at": "2026-03-31T00:00:00Z" }, + "fx-to-dotnet": { + "name": ".NET Framework to Modern .NET Migration", + "id": "fx-to-dotnet", + "description": "Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration.", + "author": "RogerBestMsft", + "version": "0.8.0", + "download_url": "https://github.com/RogerBestMsft/spec-kit-FxToNet/releases/download/v0.8.0/fx-to-dotnet.zip", + "repository": "https://github.com/RogerBestMsft/spec-kit-FxToNet", + "homepage": "https://github.com/RogerBestMsft/spec-kit-FxToNet", + "documentation": "https://github.com/RogerBestMsft/spec-kit-FxToNet/blob/main/README.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.1.0", + "tools": [ + { + "name": "Microsoft.GitHubCopilot.Modernization.Mcp", + "required": true + } + ] + }, + "provides": { + "commands": 12, + "hooks": 5 + }, + "tags": [ + "dotnet", + "migration", + "modernization", + "framework", + "aspnet", + "shared-artifact" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-06T00:00:00Z", + "updated_at": "2026-05-06T00:00:00Z" + }, "github-issues": { "name": "GitHub Issues Integration 1", "id": "github-issues", From 38bb88bde1fb1f885e4b2353590cf284711b8c76 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 15:05:14 -0500 Subject: [PATCH 177/184] docs: Add uv installation guide and inline callouts (#2465) * Initial plan * docs: Add uv installation guide and inline callouts Agent-Logs-Url: https://github.com/github/spec-kit/sessions/027c81a0-57f2-4f67-ab54-4c72f93eb254 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * docs: improve uv install guide PATH and Windows instructions Agent-Logs-Url: https://github.com/github/spec-kit/sessions/f56bcfb8-2cf5-44a5-b5e5-0fd6c3caa46f Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * docs: clarify uv note in README applies only to uv commands not pipx Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a6ada1f7-522d-4a31-ac5b-880e763f9c97 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * docs: clarify uv note in installation.md applies only to uvx commands not pipx Agent-Logs-Url: https://github.com/github/spec-kit/sessions/4ec791dd-b048-4606-8db3-671bc8956b05 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> --- README.md | 3 +++ docs/install/uv.md | 60 ++++++++++++++++++++++++++++++++++++++++++++ docs/installation.md | 3 +++ docs/toc.yml | 2 ++ 4 files changed, 68 insertions(+) create mode 100644 docs/install/uv.md diff --git a/README.md b/README.md index 86126bc6a4..5ce6f3b43d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ Choose your preferred installation method: Install once and use everywhere. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): +> [!NOTE] +> The `uv tool install` commands below require **[uv](https://docs.astral.sh/uv/)** — a fast Python package manager. If you see `command not found: uv`, [install uv first](./docs/install/uv.md). The `pipx` alternative does not require uv. + ```bash # Install a specific stable release (recommended — replace vX.Y.Z with the latest tag) uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z diff --git a/docs/install/uv.md b/docs/install/uv.md new file mode 100644 index 0000000000..21e6d23baf --- /dev/null +++ b/docs/install/uv.md @@ -0,0 +1,60 @@ +# Installing uv + +[uv](https://docs.astral.sh/uv/) is a fast Python package manager by [Astral](https://astral.sh/). Spec Kit uses `uv` (via `uvx` or `uv tool install`) to run the `specify` CLI without polluting your global Python environment. + +> [!NOTE] +> **Already have uv?** Run `uv --version` to confirm it is installed, then head back to the [Installation Guide](../installation.md). + +## Installation + +### macOS and Linux — Standalone Installer + +The quickest way to install uv on macOS or Linux is the official shell script: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +After the script finishes, follow any instructions printed by the installer to add uv to your `PATH`, then open a new terminal. + +### Windows — Standalone Installer + +Run the following in **Command Prompt or PowerShell**: + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +After the script finishes, open a new terminal so the `uv` binary is on your `PATH`. + +### macOS — Homebrew + +```bash +brew install uv +``` + +### Windows — WinGet + +```powershell +winget install --id=astral-sh.uv -e +``` + +### Windows — Scoop + +```powershell +scoop install uv +``` + +## Verification + +Confirm that uv is installed and on your `PATH`: + +```bash +uv --version +``` + +You should see output similar to `uv 0.x.y (...)`. + +## Further Reading + +For advanced options (self-update, proxy settings, uninstall, etc.) see the official [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/). diff --git a/docs/installation.md b/docs/installation.md index e53282c0b9..86ad35559f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -16,6 +16,9 @@ The easiest way to get started is to initialize a new project. Pin a specific release tag for stability (check [Releases](https://github.com/github/spec-kit/releases) for the latest): +> [!NOTE] +> The `uvx` commands below require **[uv](https://docs.astral.sh/uv/)**. If you see `command not found: uvx`, [install uv first](./install/uv.md). The `pipx` alternative does not require uv. + ```bash # Install from a specific stable release (recommended — replace vX.Y.Z with the latest tag) uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init diff --git a/docs/toc.yml b/docs/toc.yml index 636a8f03a1..4101ae742d 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -11,6 +11,8 @@ href: quickstart.md - name: Upgrade href: upgrade.md + - name: Install uv + href: install/uv.md # Reference - name: Reference From f5b675e9ee79931670303cc5db9e7f7cdb0faf76 Mon Sep 17 00:00:00 2001 From: "qiyang.yuan" <56426042+WhiteGive-Boy@users.noreply.github.com> Date: Thu, 7 May 2026 05:12:13 +0800 Subject: [PATCH 178/184] feat: Add lingma support (#2348) * add lingma support * fix * fix context file * Update CONTEXT_FILE path in test integration * fix IntegrationOption.default * fix IntegrationOption.defaultfix * fix: address Copilot review feedback - Add blank line after __future__ import (PEP 8) - Remove trailing whitespace at end of lingma/__init__.py - Bump integrations/catalog.json updated_at timestamp - Add Lingma to supported agent list in README.md * fix: address Copilot review feedback (round 4) - Reword module docstring: Lingma is a brand-new skills-only integration with no prior command-mode history, so 'deprecated since v0.5.1' wording (copied from Trae) was misleading - Remove Lingma from README CLI-tool check list: Lingma is IDE-based (requires_cli=False) and is explicitly skipped by specify init / specify check tool detection --- docs/reference/integrations.md | 1 + integrations/catalog.json | 11 ++++- src/specify_cli/integrations/__init__.py | 2 + .../integrations/lingma/__init__.py | 41 +++++++++++++++++++ tests/integrations/test_integration_lingma.py | 11 +++++ 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/specify_cli/integrations/lingma/__init__.py create mode 100644 tests/integrations/test_integration_lingma.py diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index d3f9fc6282..42332b1fe7 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -24,6 +24,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | | | [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration | | [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Alias: `--integration kiro` | +| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | | [opencode](https://opencode.ai/) | `opencode` | | | [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | diff --git a/integrations/catalog.json b/integrations/catalog.json index aad8f14f76..16e321cf58 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-04-28T00:00:00Z", + "updated_at": "2026-04-29T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "claude": { @@ -210,6 +210,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli", "skills"] }, + "lingma": { + "id": "lingma", + "name": "Lingma", + "version": "1.0.0", + "description": "Lingma IDE skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["ide", "skills"] + }, "pi": { "id": "pi", "name": "Pi Coding Agent", diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 79ada4ddfc..4a78e7d035 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -66,6 +66,7 @@ def _register_builtins() -> None: from .kilocode import KilocodeIntegration from .kimi import KimiIntegration from .kiro_cli import KiroCliIntegration + from .lingma import LingmaIntegration from .opencode import OpencodeIntegration from .pi import PiIntegration from .qodercli import QodercliIntegration @@ -97,6 +98,7 @@ def _register_builtins() -> None: _register(KilocodeIntegration()) _register(KimiIntegration()) _register(KiroCliIntegration()) + _register(LingmaIntegration()) _register(OpencodeIntegration()) _register(PiIntegration()) _register(QodercliIntegration()) diff --git a/src/specify_cli/integrations/lingma/__init__.py b/src/specify_cli/integrations/lingma/__init__.py new file mode 100644 index 0000000000..b5cd036033 --- /dev/null +++ b/src/specify_cli/integrations/lingma/__init__.py @@ -0,0 +1,41 @@ +"""Lingma IDE integration. — skills-based agent. + +Lingma IDE uses ``.lingma/skills/speckit-/SKILL.md`` layout. +In Specify CLI, the Lingma integration is skills-only, and ``--skills`` +defaults to ``True``. +""" + +from __future__ import annotations + +from ..base import IntegrationOption, SkillsIntegration + + +class LingmaIntegration(SkillsIntegration): + """Integration for Lingma IDE.""" + + key = "lingma" + config = { + "name": "Lingma", + "folder": ".lingma/", + "commands_subdir": "skills", + "install_url": None, + "requires_cli": False, + } + registrar_config = { + "dir": ".lingma/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + context_file = ".lingma/rules/specify-rules.md" + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills", + ), + ] diff --git a/tests/integrations/test_integration_lingma.py b/tests/integrations/test_integration_lingma.py new file mode 100644 index 0000000000..959de8d657 --- /dev/null +++ b/tests/integrations/test_integration_lingma.py @@ -0,0 +1,11 @@ +"""Tests for LingmaIntegration.""" + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestLingmaIntegration(SkillsIntegrationTests): + KEY = "lingma" + FOLDER = ".lingma/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".lingma/skills" + CONTEXT_FILE = ".lingma/rules/specify-rules.md" From cd44dc2147cbeaf90fc57d4114efe667cfa920c1 Mon Sep 17 00:00:00 2001 From: natelastname <44933154+natelastname@users.noreply.github.com> Date: Wed, 6 May 2026 18:21:48 -0400 Subject: [PATCH 179/184] fix(goose): Declare args parameter in generated recipes (#2402) --- src/specify_cli/integrations/base.py | 50 ++++++++++++++------ tests/integrations/test_integration_goose.py | 28 +++++++++++ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index c46340ddff..7ce107caec 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -20,6 +20,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +import yaml + if TYPE_CHECKING: from .manifest import IntegrationManifest @@ -606,6 +608,7 @@ def remove_context_section(self, project_root: Path) -> bool: # For .mdc files, treat Speckit-generated frontmatter-only content as empty if ctx_path.suffix == ".mdc": import re + # Delete the file if only YAML frontmatter remains (no body content) frontmatter_only = re.match( r"^---\n.*?\n---\s*$", normalized, re.DOTALL @@ -953,7 +956,6 @@ def _extract_description(content: str) -> str: and ``>``) keep their YAML semantics instead of being treated as raw text. """ - import yaml frontmatter_text, _ = TomlIntegration._split_frontmatter(content) if not frontmatter_text: @@ -1140,7 +1142,6 @@ def command_filename(self, template_name: str) -> str: @staticmethod def _extract_frontmatter(content: str) -> dict[str, Any]: """Extract frontmatter as a dict from YAML frontmatter block.""" - import yaml if not content.startswith("---"): return {} @@ -1201,24 +1202,38 @@ def _human_title(identifier: str) -> str: text = text[len("speckit.") :] return text.replace(".", " ").replace("-", " ").replace("_", " ").title() - @staticmethod - def _render_yaml(title: str, description: str, body: str, source_id: str) -> str: - """Render a YAML recipe file from title, description, and body. - - Produces a Goose-compatible recipe with a literal block scalar - for the prompt content. Uses ``yaml.safe_dump()`` for the - header fields to ensure proper escaping. - """ - import yaml + @classmethod + def _build_yaml_header(cls, title: str, description: str) -> dict[str, Any]: + """Build the base YAML header.""" header = { "version": "1.0.0", "title": title, "description": description, "author": {"contact": "spec-kit"}, + "parameters": [ + { + "key": "args", + "input_type": "string", + "requirement": "optional", + "default": "", + "description": "User input passed to the command.", + } + ], "extensions": [{"type": "builtin", "name": "developer"}], "activities": ["Spec-Driven Development"], } + return header + + @classmethod + def _render_yaml(cls, title: str, description: str, body: str, source_id: str) -> str: + """Render a YAML recipe file from title, description, and body. + + Produces a Goose-compatible recipe with a literal block scalar + for the prompt content. Uses ``yaml.safe_dump()`` for the + header fields to ensure proper escaping. + """ + header = cls._build_yaml_header(title, description) header_yaml = yaml.safe_dump( header, @@ -1227,12 +1242,20 @@ def _render_yaml(title: str, description: str, body: str, source_id: str) -> str default_flow_style=False, ).strip() - # Indent each line for YAML block scalar + # Indent the body for YAML block scalar indented = "\n".join(f" {line}" for line in body.split("\n")) - lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"] + lines = [ + header_yaml, + "prompt: |", + indented, + "", + f"# Source: {source_id}", + ] + return "\n".join(lines) + "\n" + def setup( self, project_root: Path, @@ -1391,7 +1414,6 @@ def setup( template. Each SKILL.md has normalised frontmatter containing ``name``, ``description``, ``compatibility``, and ``metadata``. """ - import yaml templates = self.list_command_templates() if not templates: diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py index 6483666f36..8415081d53 100644 --- a/tests/integrations/test_integration_goose.py +++ b/tests/integrations/test_integration_goose.py @@ -1,5 +1,9 @@ """Tests for GooseIntegration.""" +import yaml +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + from .test_integration_base_yaml import YamlIntegrationTests @@ -9,3 +13,27 @@ class TestGooseIntegration(YamlIntegrationTests): COMMANDS_SUBDIR = "recipes" REGISTRAR_DIR = ".goose/recipes" CONTEXT_FILE = "AGENTS.md" + + def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path): + # “If a generated Goose recipe uses {{args}} in its prompt, it + # must declare a corresponding args parameter.” + + integration = get_integration("goose") + assert integration is not None + + manifest = IntegrationManifest("goose", tmp_path) + created = integration.setup(tmp_path, manifest, script_type="sh") + + recipe_files = [path for path in created if path.suffix == ".yaml"] + assert recipe_files + + for recipe_file in recipe_files: + data = yaml.safe_load(recipe_file.read_text(encoding="utf-8")) + + if "{{args}}" not in data["prompt"]: + continue + + assert any( + param.get("key") == "args" + for param in data.get("parameters", []) + ), f"{recipe_file} uses {{{{args}}}} but does not declare args" From 11f49ebfb2f6af55345cb4bd9a7906acd211e56f Mon Sep 17 00:00:00 2001 From: Dyan Galih Date: Thu, 7 May 2026 05:47:33 +0700 Subject: [PATCH 180/184] chore: update extension versions in community catalog (#2468) * chore: update extension versions in community catalog - Update architecture-guard from v1.4.0 to v1.6.7 - Update memory-md from v0.7.5 to v0.7.9 - Update security-review from v1.4.2 to v1.4.5 All extensions now point to latest release downloads. * chore: update timestamps in community catalog Co-authored-by: Copilot --------- Co-authored-by: Copilot --- extensions/catalog.community.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index d7503a2a31..444ac85c54 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-05-06T00:00:00Z", + "updated_at": "2026-05-06T22:28:55Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -105,8 +105,8 @@ "id": "architecture-guard", "description": "Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals.", "author": "DyanGalih", - "version": "1.4.0", - "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.4.0.zip", + "version": "1.6.7", + "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.6.7.zip", "repository": "https://github.com/DyanGalih/spec-kit-architecture-guard", "homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard", "documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md", @@ -131,7 +131,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-05-05T07:26:00Z", - "updated_at": "2026-05-05T07:26:00Z" + "updated_at": "2026-05-06T22:28:55Z" }, "archive": { "name": "Archive Extension", @@ -1383,8 +1383,8 @@ "id": "memory-md", "description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context", "author": "DyanGalih", - "version": "0.7.5", - "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.5.zip", + "version": "0.7.9", + "download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.9.zip", "repository": "https://github.com/DyanGalih/spec-kit-memory-hub", "homepage": "https://github.com/DyanGalih/spec-kit-memory-hub", "documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md", @@ -1409,7 +1409,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-23T00:00:00Z", - "updated_at": "2026-05-03T00:00:00Z" + "updated_at": "2026-05-06T22:28:55Z" }, "memorylint": { "name": "MemoryLint", @@ -2085,8 +2085,8 @@ "id": "security-review", "description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews", "author": "DyanGalih", - "version": "1.4.2", - "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.2.zip", + "version": "1.4.5", + "download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.5.zip", "repository": "https://github.com/DyanGalih/spec-kit-security-review", "homepage": "https://github.com/DyanGalih/spec-kit-security-review", "documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md", @@ -2110,7 +2110,7 @@ "downloads": 0, "stars": 0, "created_at": "2026-04-03T03:24:03Z", - "updated_at": "2026-05-03T00:00:00Z" + "updated_at": "2026-05-06T22:28:55Z" }, "sf": { "name": "SFSpeckit — Salesforce Spec-Driven Development", From 5b9f0040e74b3d234f00b99c8a7490fe1b54640a Mon Sep 17 00:00:00 2001 From: Pragya Chaurasia <87864723+pragya247@users.noreply.github.com> Date: Thu, 7 May 2026 21:09:02 +0530 Subject: [PATCH 181/184] feat: add agent-orchestrator to community extension catalog (#2236) Register the Intelligent Agent Orchestrator as a community extension. Extension code is hosted externally at: https://github.com/pragya247/spec-kit-orchestrator Changes: - Add agent-orchestrator entry to extensions/catalog.community.json - Add Agent Orchestrator row to README.md community extensions table Co-authored-by: pragya247 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + extensions/catalog.community.json | 34 ++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5ce6f3b43d..dac427e4b2 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,7 @@ The following community-contributed extensions are available in [`catalog.commun | Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) | | GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) | | GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) | +| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) | | 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) | | 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) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 444ac85c54..449a0bf149 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-05-06T22:28:55Z", + "updated_at": "2026-05-07T05:51:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -68,6 +68,38 @@ "created_at": "2026-03-31T00:00:00Z", "updated_at": "2026-03-31T00:00:00Z" }, + "agent-orchestrator": { + "name": "Intelligent Agent Orchestrator", + "id": "agent-orchestrator", + "description": "Cross-catalog agent discovery and intelligent prompt-to-command routing", + "author": "pragya247", + "version": "0.1.0", + "download_url": "https://github.com/pragya247/spec-kit-orchestrator/archive/refs/tags/v0.1.0.zip", + "repository": "https://github.com/pragya247/spec-kit-orchestrator", + "homepage": "https://github.com/pragya247/spec-kit-orchestrator", + "documentation": "https://github.com/pragya247/spec-kit-orchestrator/blob/main/README.md", + "changelog": "https://github.com/pragya247/spec-kit-orchestrator/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.6.1" + }, + "provides": { + "commands": 3, + "hooks": 1 + }, + "tags": [ + "orchestrator", + "routing", + "discovery", + "agent", + "ai" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-04T00:00:00Z", + "updated_at": "2026-05-04T00:00:00Z" + }, "architect-preview": { "name": "Architect Impact Previewer", "id": "architect-preview", From 55632698318881f4b9cdd29179601c9ca76bac96 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 7 May 2026 10:46:05 -0500 Subject: [PATCH 182/184] chore: release 0.8.7, begin 0.8.8.dev0 development (#2480) * chore: bump version to 0.8.7 * chore: begin 0.8.8.dev0 development --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 602b1129d7..4aef9210bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ +## [0.8.7] - 2026-05-07 + +### Changed + +- feat: add agent-orchestrator to community extension catalog (#2236) +- chore: update extension versions in community catalog (#2468) +- fix(goose): Declare args parameter in generated recipes (#2402) +- feat: Add lingma support (#2348) +- docs: Add uv installation guide and inline callouts (#2465) +- Add fx-to-dotnet to community extension catalog (#2471) +- fix: default non-interactive init to copilot integration (#2414) +- fix(forge): use hyphen notation for command refs in Forge integration (#2462) +- feat(catalog): add Cost Tracker (cost) community extension (#2448) +- chore: release 0.8.6, begin 0.8.7.dev0 development (#2463) + ## [0.8.6] - 2026-05-06 ### Changed diff --git a/pyproject.toml b/pyproject.toml index cfeaf74fc2..d7a949d8b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "specify-cli" -version = "0.8.7.dev0" +version = "0.8.8.dev0" description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)." requires-python = ">=3.11" dependencies = [ From f0998348be26a79b546cba6170873ea730f2dc04 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 12:51:20 -0500 Subject: [PATCH 183/184] feat: Config-driven opt-in authentication registry with multi-platform support (#2393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat: add authentication provider registry (GitHub + Azure DevOps) Agent-Logs-Url: https://github.com/github/spec-kit/sessions/da7ecfd0-e1c9-48dc-b692-27be0879e976 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * feat: add try-each-provider HTTP helper and wire all catalog fetches through auth registry - Add authentication/http.py with open_url() that tries each configured provider in registry order, falling through on 401/403 to the next, and finally to unauthenticated - Add build_request() for one-shot request construction - Add configured_providers() to registry __init__ - Remove api_base_url() from AuthProvider ABC (unused) - Remove hosts attribute from providers (no host matching) - Replace _github_http.py usage in ExtensionCatalog and PresetCatalog - Wire IntegrationCatalog and WorkflowCatalog through open_url (were unauthenticated) - Wire _fetch_latest_release_tag() through open_url - Wire all inline --from-url downloads through open_url - Fix unused stub variable flagged by code-quality bot - 49 auth tests (positive + negative), 1805 total tests passing * fix: address review — fix stale docstrings, restore Accept header, add extra_headers to open_url - Fix _open_url() docstrings in extensions.py and presets.py that incorrectly claimed redirect stripping behavior - Add extra_headers parameter to open_url() so callers can pass additional headers (e.g. Accept) that persist across retries - Restore Accept: application/vnd.github+json header in _fetch_latest_release_tag() via extra_headers * feat: config-driven opt-in auth via ~/.specify/auth.json Security-first redesign: no credentials are sent unless the user explicitly creates ~/.specify/auth.json mapping hosts to providers. - Add authentication/config.py: loads and validates auth.json with host-to-provider mappings, supports token/token_env/azure-ad/azure-cli - Refactor AuthProvider ABC: auth_headers(token, scheme) + resolve_token(entry) - Refactor GitHubAuth: bearer scheme only, token from config entry - Refactor AzureDevOpsAuth: 4 schemes (basic-pat, bearer, azure-cli, azure-ad) with dynamic token acquisition for azure-cli and azure-ad - Rewrite authentication/http.py: host matching, redirect stripping, provider fallthrough on 401/403, unauthenticated fallback - Add docs/reference/authentication.md with full reference and template - 1823 tests passing (67 auth-specific) * fix: address review — unused imports, host normalization, provider+scheme validation, security hardening - Remove unused imports (os, field, Any) in config.py - Normalize hosts during load (strip + lowercase) - Validate token/token_env are non-empty strings during load - Validate provider+scheme compatibility during load - Fix extra_headers order: auth headers applied last, cannot be overridden - Remove unused 'tried' variable in http.py - Warn (once) on malformed auth.json instead of silent fallback - URL-encode OAuth2 client credentials body in azure_devops.py - Update 403 message to mention auth.json configuration - Fix registry leak in test_register_duplicate (try/finally) - Fix import style consistency in test_authentication.py - Add azure-cli and azure-ad token acquisition tests (mock subprocess/urlopen) - Add autouse fixture to isolate upgrade tests from real auth.json - 1829 tests passing * fix: reject unknown providers, validate azure-ad fields, strip Authorization from extra_headers - Reject unknown provider keys during auth.json load with clear error message - Validate azure-ad tenant_id/client_id/client_secret_env as non-empty strings - Strip Authorization from extra_headers in both build_request and open_url to prevent accidental or intentional bypass of provider-configured auth - Add tests for unknown provider and incompatible scheme validation - 1831 tests passing * fix: extract shared auth test helpers, global config isolation, align docstring - Move _inject_github_config / make_github_auth_entry to tests/auth_helpers.py to eliminate duplication across test_extensions, test_presets, test_upgrade - Move auth config isolation fixture to global conftest.py (autouse) so ALL tests are isolated from ~/.specify/auth.json, not just test_upgrade - Align load_auth_config docstring with actual behavior: ValueError may be caught by higher-level HTTP helpers that warn and continue unauthenticated - 1831 tests passing * fix: preserve auth header across multi-hop redirect chains - Read Authorization from both headers and unredirected_hdrs in _StripAuthOnRedirect to survive multi-hop chains within allowed hosts - Add test_multi_hop_redirect_within_hosts_preserves_auth - 1832 tests passing * fix: use resolved config path in warning/error messages and patch build_opener in no-network test Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: assert full resolved config path in rate-limit output test Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: close HTTPError on 401/403, remove _VALID_AUTH_SCHEMES, catch TimeoutExpired, skip POSIX test on Windows, remove unused import Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a1e29737-dd6e-4287-96c1-509e0c96fb21 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: use stable ~/.specify/auth.json in rate-limit message, skip POSIX permission check on Windows Agent-Logs-Url: https://github.com/github/spec-kit/sessions/4636bcdb-87ae-45d6-9545-a40e4effd617 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: validate host patterns, cache auth config per-process Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: clarify _is_valid_host_pattern docstring, clean up test sentinel type Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: improve _is_valid_host_pattern docstring and test observability Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/reference/authentication.md | 181 ++++ src/specify_cli/__init__.py | 42 +- src/specify_cli/authentication/__init__.py | 50 + .../authentication/azure_devops.py | 117 +++ src/specify_cli/authentication/base.py | 57 ++ src/specify_cli/authentication/config.py | 209 +++++ src/specify_cli/authentication/github.py | 24 + src/specify_cli/authentication/http.py | 149 +++ src/specify_cli/extensions.py | 16 +- src/specify_cli/integrations/catalog.py | 5 +- src/specify_cli/presets.py | 16 +- src/specify_cli/workflows/catalog.py | 4 +- tests/auth_helpers.py | 21 + tests/conftest.py | 15 + .../integrations/test_integration_catalog.py | 18 +- tests/test_authentication.py | 860 ++++++++++++++++++ tests/test_extensions.py | 92 +- tests/test_presets.py | 58 +- tests/test_upgrade.py | 91 +- 19 files changed, 1851 insertions(+), 174 deletions(-) create mode 100644 docs/reference/authentication.md create mode 100644 src/specify_cli/authentication/__init__.py create mode 100644 src/specify_cli/authentication/azure_devops.py create mode 100644 src/specify_cli/authentication/base.py create mode 100644 src/specify_cli/authentication/config.py create mode 100644 src/specify_cli/authentication/github.py create mode 100644 src/specify_cli/authentication/http.py create mode 100644 tests/auth_helpers.py create mode 100644 tests/test_authentication.py diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md new file mode 100644 index 0000000000..e25bddff84 --- /dev/null +++ b/docs/reference/authentication.md @@ -0,0 +1,181 @@ +# Authentication + +Specify CLI uses **opt-in authentication** for HTTP requests to catalog +sources, extension downloads, and release checks. No credentials are +sent unless you explicitly configure them. + +## Configuration + +Create `~/.specify/auth.json` to enable authentication: + +```json +{ + "providers": [ + { + "hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"], + "provider": "github", + "auth": "bearer", + "token_env": "GH_TOKEN" + } + ] +} +``` + +> **Security:** Restrict the file to owner-only access: +> ```bash +> chmod 600 ~/.specify/auth.json +> ``` + +Without this file, all HTTP requests are unauthenticated. + +## Fields + +Each entry in the `providers` array has the following fields: + +| Field | Required | Description | +|---|---|---| +| `hosts` | Yes | Array of hostnames this entry applies to. Supports exact hostnames, or a leading `*.` wildcard for subdomains only (for example, `*.visualstudio.com`). `*.visualstudio.com` matches `foo.visualstudio.com`, but not `visualstudio.com`. Other glob patterns such as `*github.com` or `gith?b.com` are not supported. | +| `provider` | Yes | Built-in provider key: `github` or `azure-devops`. | +| `auth` | Yes | Auth scheme (see below). | +| `token` | No | Token value (inline). Use `token_env` instead when possible. | +| `token_env` | No | Environment variable name to read the token from. | + +For `azure-ad` auth, additional fields are required: + +| Field | Required | Description | +|---|---|---| +| `tenant_id` | Yes | Azure AD tenant ID. | +| `client_id` | Yes | Service principal client ID. | +| `client_secret_env` | Yes | Environment variable containing the client secret. | + +Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes. + +## Providers and auth schemes + +### GitHub (`github`) + +| Scheme | Header | Use for | +|---|---|---| +| `bearer` | `Authorization: Bearer ` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens | + +**Example — PAT via environment variable:** + +```json +{ + "hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"], + "provider": "github", + "auth": "bearer", + "token_env": "GH_TOKEN" +} +``` + +### Azure DevOps (`azure-devops`) + +| Scheme | Header | Use for | +|---|---|---| +| `basic-pat` | `Authorization: Basic base64(:)` | Personal Access Tokens | +| `bearer` | `Authorization: Bearer ` | Pre-acquired OAuth / Azure AD tokens | +| `azure-cli` | `Authorization: Bearer ` | Token acquired via `az account get-access-token` | +| `azure-ad` | `Authorization: Bearer ` | Token acquired via OAuth2 client credentials flow | + +**Example — PAT via environment variable:** + +```json +{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "basic-pat", + "token_env": "AZURE_DEVOPS_PAT" +} +``` + +**Example — Azure CLI (interactive login):** + +```json +{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "azure-cli" +} +``` + +Requires `az login` to have been run beforehand. + +**Example — Azure AD service principal (CI/automation):** + +```json +{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "azure-ad", + "tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "client_secret_env": "AZURE_CLIENT_SECRET" +} +``` + +## Multiple entries + +You can configure multiple entries for different hosts or organizations: + +```json +{ + "providers": [ + { + "hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"], + "provider": "github", + "auth": "bearer", + "token_env": "GH_TOKEN" + }, + { + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "basic-pat", + "token_env": "AZURE_DEVOPS_PAT" + } + ] +} +``` + +## How it works + +1. For each outbound HTTP request, the URL hostname is matched against + the `hosts` patterns in `auth.json`. +2. If a match is found, the corresponding provider resolves the token + and attaches the appropriate `Authorization` header. +3. If the request receives a 401 or 403, the next matching entry is tried. +4. After all matching entries are exhausted, an unauthenticated request + is attempted as a final fallback. +5. On redirects, the `Authorization` header is stripped if the redirect + target leaves the entry's declared hosts — preventing credential + leakage to CDNs or third-party services. + +## Template + +A reference `auth.json` with GitHub pre-configured: + +```json +{ + "providers": [ + { + "hosts": [ + "github.com", + "api.github.com", + "raw.githubusercontent.com", + "codeload.github.com" + ], + "provider": "github", + "auth": "bearer", + "token_env": "GH_TOKEN" + } + ] +} +``` + +To use it: + +```bash +mkdir -p ~/.specify +# Copy the JSON above into ~/.specify/auth.json +chmod 600 ~/.specify/auth.json +``` diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f1b1d261c7..325692900e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1762,22 +1762,14 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: On anything else — including a malformed response body — the exception propagates; there is no catch-all (research D-006). """ - req = urllib.request.Request( - GITHUB_API_LATEST, - headers={"Accept": "application/vnd.github+json"}, - ) - token = None - for env_var in ("GH_TOKEN", "GITHUB_TOKEN"): - candidate = os.environ.get(env_var) - if candidate is not None: - candidate = candidate.strip() - if candidate: - token = candidate - break - if token: - req.add_header("Authorization", f"Bearer {token}") + from .authentication.http import open_url + try: - with urllib.request.urlopen(req, timeout=5) as resp: + with open_url( + GITHUB_API_LATEST, + timeout=5, + extra_headers={"Accept": "application/vnd.github+json"}, + ) as resp: payload = json.loads(resp.read().decode("utf-8")) tag = payload.get("tag_name") if not isinstance(tag, str) or not tag: @@ -1786,7 +1778,9 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: except urllib.error.HTTPError as e: # Order matters: HTTPError is a subclass of URLError. if e.code == 403: - return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)" + return None, ( + "rate limited (configure ~/.specify/auth.json with a GitHub token)" + ) return None, f"HTTP {e.code}" except (urllib.error.URLError, OSError): return None, "offline or timeout" @@ -3381,7 +3375,9 @@ def preset_add( with tempfile.TemporaryDirectory() as tmpdir: zip_path = Path(tmpdir) / "preset.zip" try: - with urllib.request.urlopen(from_url, timeout=60) as response: + from specify_cli.authentication.http import open_url as _open_url + + with _open_url(from_url, timeout=60) as response: zip_path.write_bytes(response.read()) except urllib.error.URLError as e: console.print(f"[red]Error:[/red] Failed to download: {e}") @@ -4285,7 +4281,9 @@ def extension_add( zip_path = download_dir / f"{extension}-url-download.zip" try: - with urllib.request.urlopen(from_url, timeout=60) as response: + from specify_cli.authentication.http import open_url as _open_url + + with _open_url(from_url, timeout=60) as response: zip_data = response.read() zip_path.write_bytes(zip_data) @@ -5500,7 +5498,7 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: if source.startswith("http://") or source.startswith("https://"): from ipaddress import ip_address from urllib.parse import urlparse - from urllib.request import urlopen # noqa: S310 + from specify_cli.authentication.http import open_url as _open_url parsed_src = urlparse(source) src_host = parsed_src.hostname or "" @@ -5517,7 +5515,7 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: import tempfile try: - with urlopen(source, timeout=30) as resp: # noqa: S310 + with _open_url(source, timeout=30) as resp: final_url = resp.geturl() final_parsed = urlparse(final_url) final_host = final_parsed.hostname or "" @@ -5613,10 +5611,10 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None: workflow_file = workflow_dir / "workflow.yml" try: - from urllib.request import urlopen # noqa: S310 — URL comes from catalog + from specify_cli.authentication.http import open_url as _open_url workflow_dir.mkdir(parents=True, exist_ok=True) - with urlopen(workflow_url, timeout=30) as response: # noqa: S310 + with _open_url(workflow_url, timeout=30) as response: # Validate final URL after redirects final_url = response.geturl() final_parsed = urlparse(final_url) diff --git a/src/specify_cli/authentication/__init__.py b/src/specify_cli/authentication/__init__.py new file mode 100644 index 0000000000..b4963af76b --- /dev/null +++ b/src/specify_cli/authentication/__init__.py @@ -0,0 +1,50 @@ +"""Authentication provider registry for multi-platform support. + +Credentials are **opt-in only**. No authentication headers are sent unless +the user creates ``~/.specify/auth.json`` mapping hosts to providers. +Provider classes define *how* to authenticate (Bearer, Basic-PAT, etc.) +while the config file defines *where* and *with what credentials*. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .base import AuthProvider + +# Maps provider key → AuthProvider class instance. +AUTH_REGISTRY: dict[str, AuthProvider] = {} + + +def _register(provider: AuthProvider) -> None: + """Register a provider instance in the global registry. + + Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates. + """ + key = provider.key + if not key: + raise ValueError("Cannot register provider with an empty key.") + if key in AUTH_REGISTRY: + raise KeyError(f"Provider with key {key!r} is already registered.") + AUTH_REGISTRY[key] = provider + + +def get_provider(key: str) -> AuthProvider | None: + """Return the provider for *key*, or ``None`` if not registered.""" + return AUTH_REGISTRY.get(key) + + +# -- Register built-in providers ----------------------------------------- + + +def _register_builtins() -> None: + """Register all built-in authentication providers (alphabetical).""" + from .azure_devops import AzureDevOpsAuth + from .github import GitHubAuth + + _register(AzureDevOpsAuth()) + _register(GitHubAuth()) + + +_register_builtins() diff --git a/src/specify_cli/authentication/azure_devops.py b/src/specify_cli/authentication/azure_devops.py new file mode 100644 index 0000000000..5d71a1957b --- /dev/null +++ b/src/specify_cli/authentication/azure_devops.py @@ -0,0 +1,117 @@ +"""Azure DevOps authentication provider.""" + +from __future__ import annotations + +import base64 +import json as _json +import os +import subprocess +from typing import TYPE_CHECKING + +from .base import AuthProvider + +if TYPE_CHECKING: + from .config import AuthConfigEntry + +# Azure DevOps resource ID for OAuth / Azure AD token acquisition. +_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798" + + +class AzureDevOpsAuth(AuthProvider): + """Azure DevOps authentication provider. + + Supports four auth schemes: + + * ``basic-pat`` — PAT with empty username, Base64-encoded as ``:`` + * ``bearer`` — pre-acquired OAuth / Azure AD token + * ``azure-cli`` — acquires a token via ``az account get-access-token`` + * ``azure-ad`` — acquires a token via OAuth2 client credentials flow + """ + + key = "azure-devops" + supported_auth_schemes = ("basic-pat", "bearer", "azure-cli", "azure-ad") + + def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: + """Build the ``Authorization`` header for the given scheme.""" + if auth_scheme == "basic-pat": + encoded = base64.b64encode(f":{token}".encode("ascii")).decode("ascii") + return {"Authorization": f"Basic {encoded}"} + if auth_scheme in ("bearer", "azure-cli", "azure-ad"): + return {"Authorization": f"Bearer {token}"} + raise ValueError( + f"AzureDevOpsAuth does not support auth scheme {auth_scheme!r}" + ) + + def resolve_token(self, entry: AuthConfigEntry) -> str | None: + """Resolve token, with special handling for azure-cli and azure-ad.""" + if entry.auth == "azure-cli": + return self._acquire_via_az_cli() + if entry.auth == "azure-ad": + return self._acquire_via_client_credentials(entry) + return super().resolve_token(entry) + + # -- Token acquisition ------------------------------------------------ + + @staticmethod + def _acquire_via_az_cli() -> str | None: + """Run ``az account get-access-token`` and return the access token.""" + try: + result = subprocess.run( # noqa: S603, S607 + [ + "az", + "account", + "get-access-token", + "--resource", + _ADO_RESOURCE_ID, + "--output", + "json", + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 0: + return None + payload = _json.loads(result.stdout) + token = payload.get("accessToken", "").strip() + return token or None + except (OSError, subprocess.TimeoutExpired, _json.JSONDecodeError, KeyError): + return None + + @staticmethod + def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None: + """Acquire a token via OAuth2 client credentials flow.""" + import urllib.error + import urllib.request + + if not entry.tenant_id or not entry.client_id or not entry.client_secret_env: + return None + client_secret = os.environ.get(entry.client_secret_env, "").strip() + if not client_secret: + return None + + url = ( + f"https://login.microsoftonline.com/{entry.tenant_id}" + "/oauth2/v2.0/token" + ) + from urllib.parse import urlencode + body = urlencode({ + "grant_type": "client_credentials", + "client_id": entry.client_id, + "client_secret": client_secret, + "scope": f"{_ADO_RESOURCE_ID}/.default", + }).encode("utf-8") + + req = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 + payload = _json.loads(resp.read().decode("utf-8")) + token = payload.get("access_token", "").strip() + return token or None + except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError): + return None diff --git a/src/specify_cli/authentication/base.py b/src/specify_cli/authentication/base.py new file mode 100644 index 0000000000..d6e0f4b118 --- /dev/null +++ b/src/specify_cli/authentication/base.py @@ -0,0 +1,57 @@ +"""Abstract base class for authentication providers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .config import AuthConfigEntry + + +class AuthProvider(ABC): + """Abstract base class every authentication provider must implement. + + Subclasses must set: + + * ``key`` — unique provider identifier (e.g. ``"github"``, ``"azure-devops"``) + * ``supported_auth_schemes`` — tuple of auth scheme strings this provider handles + + And implement: + + * ``auth_headers(token, auth_scheme)`` — build headers from a resolved token + * ``resolve_token(entry)`` — obtain the token for a config entry + """ + + key: str = "" + """Unique provider identifier.""" + + supported_auth_schemes: tuple[str, ...] = () + """Auth schemes this provider supports (e.g. ``("bearer",)``).""" + + @abstractmethod + def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: + """Build authentication headers for *token* using *auth_scheme*. + + Must return a dict with at least an ``Authorization`` key. + """ + + def resolve_token(self, entry: AuthConfigEntry) -> str | None: + """Resolve the token for *entry*. + + Default implementation reads from ``entry.token`` directly + or from the environment variable named by ``entry.token_env``. + Override for schemes that acquire tokens dynamically + (e.g. ``azure-cli``, ``azure-ad``). + """ + import os + + if entry.token: + return entry.token.strip() or None + if entry.token_env: + val = os.environ.get(entry.token_env) + if val is not None: + val = val.strip() + if val: + return val + return None diff --git a/src/specify_cli/authentication/config.py b/src/specify_cli/authentication/config.py new file mode 100644 index 0000000000..968cadc466 --- /dev/null +++ b/src/specify_cli/authentication/config.py @@ -0,0 +1,209 @@ +"""Authentication configuration loader. + +Reads ``~/.specify/auth.json`` to determine which hosts receive credentials +and which provider/auth-scheme to use. No credentials are sent without +an explicit opt-in via this file. +""" + +from __future__ import annotations + +import json +import os +import stat +from dataclasses import dataclass +from fnmatch import fnmatch +from pathlib import Path +from urllib.parse import urlparse + + +@dataclass(frozen=True) +class AuthConfigEntry: + """A single provider entry from ``auth.json``.""" + + hosts: tuple[str, ...] + provider: str + auth: str + token: str | None = None + token_env: str | None = None + # Azure AD service-principal fields + tenant_id: str | None = None + client_id: str | None = None + client_secret_env: str | None = None + + +def _default_config_path() -> Path: + """Return ``~/.specify/auth.json``.""" + return Path.home() / ".specify" / "auth.json" + + +def _is_valid_host_pattern(pattern: str) -> bool: + """Return True for safe host patterns: exact hostnames or ``*.suffix`` only. + + Rejects patterns like ``*github.com`` (which would match + ``github.com.evil.com``) or multi-wildcard forms. Only these two + forms are accepted: + + * ``example.com`` — exact hostname + * ``*.example.com`` — leading ``*.`` wildcard; matches subdomains + such as ``myorg.example.com`` but not ``example.com`` itself + """ + if "*" not in pattern: + return True # exact hostname — already validated as non-empty + # Only *.suffix is allowed; no other wildcard positions + return pattern.startswith("*.") and "*" not in pattern[2:] + + +def load_auth_config( + path: Path | None = None, +) -> list[AuthConfigEntry]: + """Load and validate ``auth.json``, returning configured entries. + + Returns an empty list when the file does not exist — this means + all HTTP requests will be unauthenticated (opt-in model). + + Raises ``ValueError`` on schema violations. Callers that want + misconfigurations to fail fast can allow this exception to + propagate; higher-level HTTP helpers may instead catch it, + warn, and continue with unauthenticated requests. + """ + config_path = path or _default_config_path() + + if not config_path.is_file(): + return [] + + # Warn (but don't fail) if the file is world-readable (POSIX only). + if os.name != "nt": + try: + mode = config_path.stat().st_mode + if mode & (stat.S_IRGRP | stat.S_IROTH): + import warnings + + warnings.warn( + f"{config_path} is readable by group/others. " + "Consider restricting with: chmod 600 " + f"{config_path}", + UserWarning, + stacklevel=2, + ) + except OSError: + pass # stat failed — skip permission check + + raw = json.loads(config_path.read_text(encoding="utf-8")) + + if not isinstance(raw, dict): + raise ValueError(f"auth.json must be a JSON object, got {type(raw).__name__}") + + providers_raw = raw.get("providers") + if not isinstance(providers_raw, list): + raise ValueError("auth.json must contain a 'providers' array") + + entries: list[AuthConfigEntry] = [] + for i, entry_raw in enumerate(providers_raw): + if not isinstance(entry_raw, dict): + raise ValueError(f"providers[{i}]: must be a JSON object") + + hosts = entry_raw.get("hosts") + if not isinstance(hosts, list) or not hosts: + raise ValueError(f"providers[{i}]: 'hosts' must be a non-empty array") + if not all(isinstance(h, str) and h.strip() for h in hosts): + raise ValueError(f"providers[{i}]: each host must be a non-empty string") + # Normalize hosts: strip whitespace and lowercase + hosts = [h.strip().lower() for h in hosts] + # Reject dangerous wildcard forms (e.g. *github.com matches github.com.evil.com) + for h in hosts: + if not _is_valid_host_pattern(h): + raise ValueError( + f"providers[{i}]: invalid host pattern {h!r}. " + "Only exact hostnames or '*.suffix' forms are allowed " + "(e.g. 'github.com' or '*.visualstudio.com')." + ) + + provider = entry_raw.get("provider", "") + if not isinstance(provider, str) or not provider: + raise ValueError(f"providers[{i}]: 'provider' must be a non-empty string") + + auth = entry_raw.get("auth", "") + if not isinstance(auth, str) or not auth: + raise ValueError(f"providers[{i}]: 'auth' must be a non-empty string") + + token = entry_raw.get("token") + token_env = entry_raw.get("token_env") + + # Validate token/token_env types + if token is not None and (not isinstance(token, str) or not token.strip()): + raise ValueError(f"providers[{i}]: 'token' must be a non-empty string") + if token_env is not None and (not isinstance(token_env, str) or not token_env.strip()): + raise ValueError(f"providers[{i}]: 'token_env' must be a non-empty string") + + # Validate provider+scheme compatibility + from . import get_provider as _get_provider + _prov = _get_provider(provider) + if _prov is None: + from . import AUTH_REGISTRY + raise ValueError( + f"providers[{i}]: unknown provider {provider!r}; " + f"registered: {sorted(AUTH_REGISTRY.keys())}" + ) + if auth not in _prov.supported_auth_schemes: + raise ValueError( + f"providers[{i}]: provider {provider!r} does not support " + f"auth scheme {auth!r}; supported: {list(_prov.supported_auth_schemes)}" + ) + + # Validate token source based on auth scheme + if auth in ("bearer", "basic-pat"): + if not token and not token_env: + raise ValueError( + f"providers[{i}]: auth={auth!r} requires 'token' or 'token_env'" + ) + elif auth == "azure-ad": + tenant_id = entry_raw.get("tenant_id") + client_id = entry_raw.get("client_id") + client_secret_env = entry_raw.get("client_secret_env") + if not all([tenant_id, client_id, client_secret_env]): + raise ValueError( + f"providers[{i}]: auth='azure-ad' requires " + "'tenant_id', 'client_id', and 'client_secret_env'" + ) + for field_name, field_val in [ + ("tenant_id", tenant_id), + ("client_id", client_id), + ("client_secret_env", client_secret_env), + ]: + if not isinstance(field_val, str) or not field_val.strip(): + raise ValueError( + f"providers[{i}]: '{field_name}' must be a non-empty string" + ) + # azure-cli needs no extra fields + + entries.append( + AuthConfigEntry( + hosts=tuple(hosts), + provider=provider, + auth=auth, + token=token, + token_env=token_env, + tenant_id=entry_raw.get("tenant_id"), + client_id=entry_raw.get("client_id"), + client_secret_env=entry_raw.get("client_secret_env"), + ) + ) + + return entries + + +def find_entries_for_url( + url: str, entries: list[AuthConfigEntry] +) -> list[AuthConfigEntry]: + """Return entries whose ``hosts`` match the hostname of *url*.""" + hostname = (urlparse(url).hostname or "").lower() + if not hostname: + return [] + return [ + e + for e in entries + if any( + pattern == hostname or fnmatch(hostname, pattern) + for pattern in e.hosts + ) + ] diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py new file mode 100644 index 0000000000..3797d82926 --- /dev/null +++ b/src/specify_cli/authentication/github.py @@ -0,0 +1,24 @@ +"""GitHub authentication provider.""" + +from __future__ import annotations + +from .base import AuthProvider + + +class GitHubAuth(AuthProvider): + """GitHub authentication provider. + + Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs, + OAuth tokens, and GitHub App installation tokens. + """ + + key = "github" + supported_auth_schemes = ("bearer",) + + def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: + """Return ``Authorization: Bearer ``.""" + if auth_scheme != "bearer": + raise ValueError( + f"GitHubAuth does not support auth scheme {auth_scheme!r}" + ) + return {"Authorization": f"Bearer {token}"} diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py new file mode 100644 index 0000000000..d6402e5c3e --- /dev/null +++ b/src/specify_cli/authentication/http.py @@ -0,0 +1,149 @@ +"""Authenticated HTTP helpers driven by ``~/.specify/auth.json``. + +No credentials are sent unless the user has created ``auth.json``. +For each outbound URL the helper matches the hostname against +configured entries, resolves the token via the appropriate provider +class, and attaches auth headers. Redirect safety is enforced: +the ``Authorization`` header is stripped when a redirect leaves the +entry's declared hosts. On 401/403 the next matching entry is tried, +then unauthenticated. +""" + +from __future__ import annotations + +import urllib.error +import urllib.request +from fnmatch import fnmatch +from urllib.parse import urlparse + +from . import get_provider +from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config + + +_config_override: list[AuthConfigEntry] | None = None +_config_cache: list[AuthConfigEntry] | None = None # None = not yet loaded + + +def _load_config() -> list[AuthConfigEntry]: + """Load auth config, using override if set (for testing). + + The result is cached per-process so ``auth.json`` is read at most once, + and any warning about a malformed file fires only once. + """ + global _config_cache + if _config_override is not None: + return _config_override + if _config_cache is not None: + return _config_cache + try: + _config_cache = load_auth_config() + except (ValueError, OSError) as exc: + import warnings + config_path = _default_config_path() + warnings.warn( + f"Failed to load {config_path}: {exc}. " + "All requests will be unauthenticated.", + UserWarning, + stacklevel=2, + ) + _config_cache = [] + return _config_cache + + +def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool: + """Return True if *hostname* matches any pattern in *hosts*.""" + hostname = hostname.lower() + return any(p == hostname or fnmatch(hostname, p) for p in hosts) + + +class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler): + """Drop ``Authorization`` when a redirect leaves the entry's declared hosts.""" + + def __init__(self, hosts: tuple[str, ...]) -> None: + super().__init__() + self._hosts = hosts + + def redirect_request(self, req, fp, code, msg, headers, newurl): + original_auth = ( + req.get_header("Authorization") + or req.unredirected_hdrs.get("Authorization") + ) + new_req = super().redirect_request(req, fp, code, msg, headers, newurl) + if new_req is not None: + hostname = (urlparse(newurl).hostname or "").lower() + if _hostname_in_hosts(hostname, self._hosts): + if original_auth: + new_req.add_unredirected_header("Authorization", original_auth) + else: + new_req.headers.pop("Authorization", None) + new_req.unredirected_hdrs.pop("Authorization", None) + return new_req + + +def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urllib.request.Request: + """Build a :class:`~urllib.request.Request`, attaching auth when config matches. + + Uses the first matching entry from ``auth.json`` whose token resolves. + Returns a plain request when no entry matches or the file doesn't exist. + """ + headers: dict[str, str] = {} + if extra_headers: + # Strip Authorization from extra_headers to prevent bypass + headers.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"}) + # Auth headers applied last — cannot be overridden by extra_headers + entries = find_entries_for_url(url, _load_config()) + for entry in entries: + provider = get_provider(entry.provider) + if provider is None: + continue + token = provider.resolve_token(entry) + if token: + headers.update(provider.auth_headers(token, entry.auth)) + break + return urllib.request.Request(url, headers=headers) + + +def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None): + """Open *url* with config-driven auth, redirect stripping, and fallthrough. + + 1. Find ``auth.json`` entries whose hosts match the URL. + 2. For each entry, resolve the token and try the request. + 3. On 401/403 move to the next matching entry. + 4. After all entries exhausted (or none matched), try unauthenticated. + 5. Non-auth errors (404, 500, network) raise immediately. + + *extra_headers* (e.g. ``Accept``) are merged into every attempt. + """ + entries = find_entries_for_url(url, _load_config()) + + def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request: + merged = {} + if extra_headers: + # Strip Authorization from extra_headers to prevent bypass + merged.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"}) + # Auth headers applied last — cannot be overridden by extra_headers + merged.update(auth_headers) + return urllib.request.Request(url, headers=merged) + + # Try each matching entry + for entry in entries: + provider = get_provider(entry.provider) + if provider is None: + continue + token = provider.resolve_token(entry) + if not token: + continue + + req = _make_req(provider.auth_headers(token, entry.auth)) + opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts)) + try: + return opener.open(req, timeout=timeout) + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + exc.close() + continue # try next entry + raise + + # No entry worked (or none matched) — unauthenticated fallback + req = _make_req({}) + return urllib.request.urlopen(req, timeout=timeout) # noqa: S310 diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 81687b4186..944ee4a06d 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1707,20 +1707,20 @@ def _validate_catalog_url(self, url: str) -> None: raise ValidationError("Catalog URL must be a valid URL with a host.") def _make_request(self, url: str): - """Build a urllib Request, adding a GitHub auth header when available. + """Build a urllib Request, adding auth headers when a provider matches. - Delegates to :func:`specify_cli._github_http.build_github_request`. + Delegates to :func:`specify_cli.authentication.http.build_request`. """ - from specify_cli._github_http import build_github_request - return build_github_request(url) + from specify_cli.authentication.http import build_request + return build_request(url) def _open_url(self, url: str, timeout: int = 10): - """Open a URL with GitHub auth, stripping the header on cross-host redirects. + """Open a URL with provider-based auth, trying each configured provider. - Delegates to :func:`specify_cli._github_http.open_github_url`. + Delegates to :func:`specify_cli.authentication.http.open_url`. """ - from specify_cli._github_http import open_github_url - return open_github_url(url, timeout) + from specify_cli.authentication.http import open_url + return open_url(url, timeout) def _load_catalog_config(self, config_path: Path) -> Optional[List[CatalogEntry]]: """Load catalog stack configuration from a YAML file. diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py index 1b449af682..8ea1f4722c 100644 --- a/src/specify_cli/integrations/catalog.py +++ b/src/specify_cli/integrations/catalog.py @@ -265,7 +265,6 @@ def _fetch_single_catalog( ) -> Dict[str, Any]: """Fetch one catalog, with per-URL caching.""" import urllib.error - import urllib.request url_hash = hashlib.sha256(entry.url.encode()).hexdigest()[:16] cache_file = self.cache_dir / f"catalog-{url_hash}.json" @@ -289,7 +288,9 @@ def _fetch_single_catalog( pass # Cache cleanup is best-effort; ignore deletion failures. try: - with urllib.request.urlopen(entry.url, timeout=10) as resp: + from specify_cli.authentication.http import open_url + + with open_url(entry.url, timeout=10) as resp: # Validate final URL after redirects final_url = resp.geturl() if final_url != entry.url: diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 690d1c51ff..041c832e45 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -1845,20 +1845,20 @@ def _validate_catalog_url(self, url: str) -> None: ) def _make_request(self, url: str): - """Build a urllib Request, adding a GitHub auth header when available. + """Build a urllib Request, adding auth headers when a provider matches. - Delegates to :func:`specify_cli._github_http.build_github_request`. + Delegates to :func:`specify_cli.authentication.http.build_request`. """ - from specify_cli._github_http import build_github_request - return build_github_request(url) + from specify_cli.authentication.http import build_request + return build_request(url) def _open_url(self, url: str, timeout: int = 10): - """Open a URL with GitHub auth, stripping the header on cross-host redirects. + """Open a URL with provider-based auth, trying each configured provider. - Delegates to :func:`specify_cli._github_http.open_github_url`. + Delegates to :func:`specify_cli.authentication.http.open_url`. """ - from specify_cli._github_http import open_github_url - return open_github_url(url, timeout) + from specify_cli.authentication.http import open_url + return open_url(url, timeout) def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalogEntry]]: """Load catalog stack configuration from a YAML file. diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py index da5c60b5c8..213b443e3d 100644 --- a/src/specify_cli/workflows/catalog.py +++ b/src/specify_cli/workflows/catalog.py @@ -322,7 +322,7 @@ def _fetch_single_catalog( # Fetch from URL — validate scheme before opening and after redirects from urllib.parse import urlparse - from urllib.request import urlopen + from specify_cli.authentication.http import open_url as _open_url def _validate_catalog_url(url: str) -> None: parsed = urlparse(url) @@ -337,7 +337,7 @@ def _validate_catalog_url(url: str) -> None: _validate_catalog_url(entry.url) try: - with urlopen(entry.url, timeout=30) as resp: # noqa: S310 + with _open_url(entry.url, timeout=30) as resp: _validate_catalog_url(resp.geturl()) data = json.loads(resp.read().decode("utf-8")) except Exception as exc: diff --git a/tests/auth_helpers.py b/tests/auth_helpers.py new file mode 100644 index 0000000000..babc43e406 --- /dev/null +++ b/tests/auth_helpers.py @@ -0,0 +1,21 @@ +"""Shared test helpers for authentication config injection.""" + +from __future__ import annotations + +from specify_cli.authentication.config import AuthConfigEntry + + +def make_github_auth_entry(token_env: str = "GH_TOKEN") -> AuthConfigEntry: + """Build a GitHub ``AuthConfigEntry`` for testing.""" + return AuthConfigEntry( + hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"), + provider="github", + auth="bearer", + token_env=token_env, + ) + + +def inject_github_config(monkeypatch, token_env: str = "GH_TOKEN") -> None: + """Inject a GitHub auth.json config entry into the auth HTTP module.""" + from specify_cli.authentication import http as _auth_http + monkeypatch.setattr(_auth_http, "_config_override", [make_github_auth_entry(token_env)]) diff --git a/tests/conftest.py b/tests/conftest.py index 9e8ffaae59..0e568a1e2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,3 +66,18 @@ def _has_working_bash() -> bool: def strip_ansi(text: str) -> str: """Remove ANSI escape codes from Rich-formatted CLI output.""" return _ANSI_ESCAPE_RE.sub("", text) + + +# --------------------------------------------------------------------------- +# Auth config isolation — prevents tests from reading ~/.specify/auth.json +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _isolate_auth_config(monkeypatch): + """Ensure no test reads the real ~/.specify/auth.json.""" + from specify_cli.authentication import http as _auth_http + monkeypatch.setattr(_auth_http, "_config_override", []) + # Also clear the per-process cache so tests that unset _config_override + # won't see a previously cached real-file result. + monkeypatch.setattr(_auth_http, "_config_cache", None) diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 8b21ddfb8b..2e285e17e1 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -166,12 +166,12 @@ class TestCatalogFetch: """Tests that use a local HTTP server stub via monkeypatch.""" def _patch_urlopen(self, monkeypatch, catalog_data): - """Patch urllib.request.urlopen to return *catalog_data*.""" + """Patch authentication.http.urllib.request.urlopen to return *catalog_data*.""" class FakeResponse: def __init__(self, data, url=""): self._data = json.dumps(data).encode() - self._url = url + self._url = url if isinstance(url, str) else url.full_url def read(self): return self._data @@ -185,11 +185,12 @@ def __enter__(self): def __exit__(self, *a): pass - def fake_urlopen(url, timeout=10): + def fake_urlopen(req, timeout=10): + url = req if isinstance(req, str) else req.full_url return FakeResponse(catalog_data, url) - import urllib.request - monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) + import specify_cli.authentication.http as _auth_http + monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen) def test_fetch_and_search_all(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) @@ -486,12 +487,12 @@ def test_list_catalog_flag(self, tmp_path, monkeypatch): }, } - import urllib.request + import specify_cli.authentication.http as _auth_http class FakeResponse: def __init__(self, data, url=""): self._data = json.dumps(data).encode() - self._url = url + self._url = url if isinstance(url, str) else url.full_url def read(self): return self._data def geturl(self): @@ -501,7 +502,8 @@ def __enter__(self): def __exit__(self, *a): pass - monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url)) + monkeypatch.setattr(_auth_http.urllib.request, "urlopen", + lambda req, timeout=10: FakeResponse(catalog, req if isinstance(req, str) else req.full_url)) old = os.getcwd() try: diff --git a/tests/test_authentication.py b/tests/test_authentication.py new file mode 100644 index 0000000000..938cb87650 --- /dev/null +++ b/tests/test_authentication.py @@ -0,0 +1,860 @@ +"""Tests for the authentication provider registry and config-driven HTTP helpers. + +Covers: +- Config loading (auth.json parsing, validation, permission warning) +- Registry mechanics (_register, get_provider, duplicate/empty-key guards) +- GitHubAuth — bearer headers +- AzureDevOpsAuth — basic-pat, bearer, azure-cli, azure-ad headers +- Host matching (find_entries_for_url) +- open_url — config-driven auth with fallthrough and redirect stripping +- build_request — single-shot request construction +- _fetch_latest_release_tag() delegation +""" + +from __future__ import annotations + +import base64 +import json +import os + +import pytest + +from specify_cli.authentication import AUTH_REGISTRY, _register, get_provider +from specify_cli.authentication.azure_devops import AzureDevOpsAuth +from specify_cli.authentication.base import AuthProvider +from specify_cli.authentication.config import ( + AuthConfigEntry, + find_entries_for_url, + load_auth_config, +) +from specify_cli.authentication.github import GitHubAuth + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> AuthConfigEntry: + """Build a standard GitHub config entry.""" + return AuthConfigEntry( + hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"), + provider="github", + auth="bearer", + token=token, + token_env=token_env if token is None else None, + ) + + +def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry: + """Build an ADO basic-pat config entry.""" + return AuthConfigEntry( + hosts=("dev.azure.com",), + provider="azure-devops", + auth="basic-pat", + token_env=token_env, + ) + + +class _StubProvider(AuthProvider): + """Minimal concrete provider for registry mechanics tests.""" + + key = "stub-provider" + supported_auth_schemes = ("bearer",) + + def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# Config loading +# --------------------------------------------------------------------------- + + +class TestLoadAuthConfig: + def test_missing_file_returns_empty(self, tmp_path): + assert load_auth_config(tmp_path / "nonexistent.json") == [] + + def test_valid_github_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["github.com"], + "provider": "github", + "auth": "bearer", + "token_env": "GH_TOKEN", + }] + })) + entries = load_auth_config(cfg) + assert len(entries) == 1 + assert entries[0].provider == "github" + assert entries[0].auth == "bearer" + assert entries[0].token_env == "GH_TOKEN" + + def test_valid_ado_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "basic-pat", + "token_env": "AZURE_DEVOPS_PAT", + }] + })) + entries = load_auth_config(cfg) + assert len(entries) == 1 + assert entries[0].provider == "azure-devops" + assert entries[0].auth == "basic-pat" + + def test_inline_token(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["github.com"], + "provider": "github", + "auth": "bearer", + "token": "ghp_inline_token", + }] + })) + entries = load_auth_config(cfg) + assert entries[0].token == "ghp_inline_token" + + def test_azure_ad_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "azure-ad", + "tenant_id": "tid", + "client_id": "cid", + "client_secret_env": "SECRET", + }] + })) + entries = load_auth_config(cfg) + assert entries[0].auth == "azure-ad" + assert entries[0].tenant_id == "tid" + + def test_azure_cli_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "azure-cli", + }] + })) + entries = load_auth_config(cfg) + assert entries[0].auth == "azure-cli" + + def test_multiple_entries(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [ + {"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}, + {"hosts": ["dev.azure.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "ADO_PAT"}, + ] + })) + entries = load_auth_config(cfg) + assert len(entries) == 2 + + # -- Negative: validation errors -- + + def test_invalid_json_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text("not json") + with pytest.raises(json.JSONDecodeError): + load_auth_config(cfg) + + def test_not_object_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text("[]") + with pytest.raises(ValueError, match="JSON object"): + load_auth_config(cfg) + + def test_missing_providers_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({"foo": "bar"})) + with pytest.raises(ValueError, match="providers"): + load_auth_config(cfg) + + def test_empty_hosts_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": [], "provider": "github", "auth": "bearer", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="non-empty"): + load_auth_config(cfg) + + def test_missing_provider_key_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["github.com"], "auth": "bearer", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="provider"): + load_auth_config(cfg) + + def test_unsupported_auth_scheme_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["github.com"], "provider": "github", "auth": "ntlm", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="does not support"): + load_auth_config(cfg) + + def test_bearer_without_token_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer"}] + })) + with pytest.raises(ValueError, match="token"): + load_auth_config(cfg) + + def test_azure_ad_missing_fields_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["dev.azure.com"], + "provider": "azure-devops", + "auth": "azure-ad", + "tenant_id": "tid", + }] + })) + with pytest.raises(ValueError, match="azure-ad"): + load_auth_config(cfg) + + def test_unknown_provider_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["example.com"], "provider": "gitlab", "auth": "bearer", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="unknown provider"): + load_auth_config(cfg) + + def test_incompatible_provider_scheme_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["github.com"], + "provider": "github", + "auth": "basic-pat", + "token_env": "X", + }] + })) + with pytest.raises(ValueError, match="does not support"): + load_auth_config(cfg) + + def test_dangerous_wildcard_host_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["*github.com"], "provider": "github", "auth": "bearer", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="invalid host pattern"): + load_auth_config(cfg) + + def test_multi_wildcard_host_raises(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["*.*.example.com"], "provider": "github", "auth": "bearer", "token_env": "X"}] + })) + with pytest.raises(ValueError, match="invalid host pattern"): + load_auth_config(cfg) + + def test_valid_star_dot_host_accepted(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["*.visualstudio.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "X"}] + })) + entries = load_auth_config(cfg) + assert entries[0].hosts == ("*.visualstudio.com",) + + @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits not supported on Windows") + def test_world_readable_warns(self, tmp_path): + import stat + + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}] + })) + cfg.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + with pytest.warns(UserWarning, match="readable by group"): + load_auth_config(cfg) + + +# --------------------------------------------------------------------------- +# Host matching +# --------------------------------------------------------------------------- + + +class TestFindEntriesForUrl: + def test_exact_match(self): + entry = _github_entry() + result = find_entries_for_url("https://github.com/org/repo", [entry]) + assert result == [entry] + + def test_wildcard_match(self): + entry = AuthConfigEntry( + hosts=("*.visualstudio.com",), + provider="azure-devops", + auth="basic-pat", + token_env="ADO_PAT", + ) + result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry]) + assert result == [entry] + + def test_no_match_returns_empty(self): + entry = _github_entry() + result = find_entries_for_url("https://evil.example.com/file", [entry]) + assert result == [] + + def test_no_match_for_lookalike_host(self): + entry = _github_entry() + result = find_entries_for_url("https://github.com.evil.com/file", [entry]) + assert result == [] + + def test_empty_url_returns_empty(self): + assert find_entries_for_url("", [_github_entry()]) == [] + + def test_empty_entries_returns_empty(self): + assert find_entries_for_url("https://github.com/org/repo", []) == [] + + def test_multiple_matches_returned(self): + e1 = _github_entry(token_env="GH_TOKEN") + e2 = _github_entry(token_env="GITHUB_TOKEN") + result = find_entries_for_url("https://github.com/org/repo", [e1, e2]) + assert len(result) == 2 + + +# --------------------------------------------------------------------------- +# Registry mechanics +# --------------------------------------------------------------------------- + + +class TestAuthRegistry: + def test_github_registered(self): + assert "github" in AUTH_REGISTRY + + def test_azure_devops_registered(self): + assert "azure-devops" in AUTH_REGISTRY + + def test_get_provider_returns_github(self): + assert isinstance(get_provider("github"), GitHubAuth) + + def test_get_provider_returns_azure_devops(self): + assert isinstance(get_provider("azure-devops"), AzureDevOpsAuth) + + def test_get_provider_unknown_returns_none(self): + assert get_provider("does-not-exist") is None + + def test_register_duplicate_raises_key_error(self): + class _UniqueStub(_StubProvider): + key = "__test_duplicate__" + + try: + _register(_UniqueStub()) + with pytest.raises(KeyError, match="already registered"): + _register(_UniqueStub()) + finally: + AUTH_REGISTRY.pop("__test_duplicate__", None) + + def test_register_empty_key_raises_value_error(self): + class _EmptyKey(_StubProvider): + key = "" + + with pytest.raises(ValueError, match="empty key"): + _register(_EmptyKey()) + + +# --------------------------------------------------------------------------- +# GitHubAuth +# --------------------------------------------------------------------------- + + +class TestGitHubAuth: + def test_bearer_headers(self): + assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"} + + def test_unsupported_scheme_raises(self): + with pytest.raises(ValueError, match="basic-pat"): + GitHubAuth().auth_headers("tok", "basic-pat") + + def test_resolve_token_from_env(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", "env-token") + assert GitHubAuth().resolve_token(_github_entry()) == "env-token" + + def test_resolve_token_inline(self): + assert GitHubAuth().resolve_token(_github_entry(token="inline-tok")) == "inline-tok" + + def test_resolve_token_strips_whitespace(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", " my-token ") + assert GitHubAuth().resolve_token(_github_entry()) == "my-token" + + def test_resolve_token_empty_env_returns_none(self, monkeypatch): + monkeypatch.setenv("GH_TOKEN", " ") + assert GitHubAuth().resolve_token(_github_entry()) is None + + def test_resolve_token_missing_env_returns_none(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + assert GitHubAuth().resolve_token(_github_entry()) is None + + def test_key(self): + assert GitHubAuth.key == "github" + + def test_supported_schemes(self): + assert GitHubAuth.supported_auth_schemes == ("bearer",) + + +# --------------------------------------------------------------------------- +# AzureDevOpsAuth +# --------------------------------------------------------------------------- + + +class TestAzureDevOpsAuth: + def test_basic_pat_headers(self): + headers = AzureDevOpsAuth().auth_headers("my-pat", "basic-pat") + encoded = base64.b64encode(b":my-pat").decode("ascii") + assert headers == {"Authorization": f"Basic {encoded}"} + + def test_basic_pat_format(self): + header = AzureDevOpsAuth().auth_headers("test-pat", "basic-pat")["Authorization"] + raw = base64.b64decode(header[len("Basic "):]).decode("ascii") + assert raw == ":test-pat" + + def test_bearer_headers(self): + assert AzureDevOpsAuth().auth_headers("tok", "bearer") == {"Authorization": "Bearer tok"} + + def test_azure_cli_headers(self): + assert AzureDevOpsAuth().auth_headers("tok", "azure-cli") == {"Authorization": "Bearer tok"} + + def test_azure_ad_headers(self): + assert AzureDevOpsAuth().auth_headers("tok", "azure-ad") == {"Authorization": "Bearer tok"} + + def test_unsupported_scheme_raises(self): + with pytest.raises(ValueError): + AzureDevOpsAuth().auth_headers("tok", "ntlm") + + def test_resolve_token_basic_pat(self, monkeypatch): + monkeypatch.setenv("AZURE_DEVOPS_PAT", "my-pat") + assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat" + + def test_resolve_token_strips_whitespace(self, monkeypatch): + monkeypatch.setenv("AZURE_DEVOPS_PAT", " my-pat ") + assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat" + + def test_resolve_token_missing_returns_none(self, monkeypatch): + monkeypatch.delenv("AZURE_DEVOPS_PAT", raising=False) + assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) is None + + def test_key(self): + assert AzureDevOpsAuth.key == "azure-devops" + + def test_supported_schemes(self): + schemes = AzureDevOpsAuth.supported_auth_schemes + assert "basic-pat" in schemes + assert "bearer" in schemes + assert "azure-cli" in schemes + assert "azure-ad" in schemes + + def test_resolve_token_azure_cli_success(self): + """azure-cli acquires token via az CLI.""" + from unittest.mock import patch, MagicMock + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + result = MagicMock() + result.returncode = 0 + result.stdout = '{"accessToken": "cli-acquired-token"}' + with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result): + assert AzureDevOpsAuth().resolve_token(entry) == "cli-acquired-token" + + def test_resolve_token_azure_cli_failure_returns_none(self): + """azure-cli returns None when az CLI fails.""" + from unittest.mock import patch, MagicMock + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + result = MagicMock() + result.returncode = 1 + result.stdout = "" + with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result): + assert AzureDevOpsAuth().resolve_token(entry) is None + + def test_resolve_token_azure_cli_not_installed_returns_none(self): + """azure-cli returns None when az is not installed.""" + from unittest.mock import patch + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")): + assert AzureDevOpsAuth().resolve_token(entry) is None + + def test_resolve_token_azure_ad_success(self, monkeypatch): + """azure-ad acquires token via OAuth2 client credentials.""" + from unittest.mock import patch, MagicMock + monkeypatch.setenv("MY_SECRET", "secret-value") + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad", + tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET", + ) + mock_resp = MagicMock() + mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}' + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + with patch("urllib.request.urlopen", return_value=mock_resp): + assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token" + + def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch): + """azure-ad returns None when client secret env var is missing.""" + monkeypatch.delenv("MY_SECRET", raising=False) + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad", + tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET", + ) + assert AzureDevOpsAuth().resolve_token(entry) is None + + def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch): + """azure-ad returns None on network errors.""" + import urllib.error + from unittest.mock import patch + monkeypatch.setenv("MY_SECRET", "secret-value") + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad", + tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET", + ) + with patch("urllib.request.urlopen", + side_effect=urllib.error.URLError("connection refused")): + assert AzureDevOpsAuth().resolve_token(entry) is None + + +# --------------------------------------------------------------------------- +# open_url / build_request — positive tests +# --------------------------------------------------------------------------- + + +class TestAuthenticatedHttp: + def _set_config(self, monkeypatch, entries): + from specify_cli.authentication import http as _mod + monkeypatch.setattr(_mod, "_config_override", entries) + + def test_build_request_attaches_auth_for_matching_host(self, monkeypatch): + from specify_cli.authentication.http import build_request + monkeypatch.setenv("GH_TOKEN", "my-token") + self._set_config(monkeypatch, [_github_entry()]) + req = build_request("https://github.com/org/repo") + assert req.get_header("Authorization") == "Bearer my-token" + + def test_build_request_no_auth_for_non_matching_host(self, monkeypatch): + from specify_cli.authentication.http import build_request + monkeypatch.setenv("GH_TOKEN", "my-token") + self._set_config(monkeypatch, [_github_entry()]) + req = build_request("https://evil.example.com/file") + assert "Authorization" not in req.headers + + def test_build_request_no_auth_when_no_config(self, monkeypatch): + from specify_cli.authentication.http import build_request + self._set_config(monkeypatch, []) + req = build_request("https://github.com/org/repo") + assert "Authorization" not in req.headers + + def test_build_request_extra_headers(self, monkeypatch): + from specify_cli.authentication.http import build_request + monkeypatch.setenv("GH_TOKEN", "my-token") + self._set_config(monkeypatch, [_github_entry()]) + req = build_request("https://github.com/api", extra_headers={"Accept": "application/json"}) + assert req.get_header("Accept") == "application/json" + assert req.get_header("Authorization") == "Bearer my-token" + + def test_open_url_attaches_auth_for_matching_host(self, monkeypatch): + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + monkeypatch.setenv("GH_TOKEN", "my-token") + self._set_config(monkeypatch, [_github_entry()]) + captured = {} + mock_opener = MagicMock() + def fake_open(req, timeout=None): + captured["req"] = req + resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + return resp + mock_opener.open.side_effect = fake_open + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + open_url("https://github.com/org/repo/catalog.json") + assert captured["req"].get_header("Authorization") == "Bearer my-token" + + def test_open_url_no_auth_for_non_matching_host(self, monkeypatch): + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + monkeypatch.setenv("GH_TOKEN", "my-token") + self._set_config(monkeypatch, [_github_entry()]) + captured = {} + def fake_urlopen(req, timeout=None): + captured["req"] = req + resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + return resp + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen): + open_url("https://example.com/file.json") + assert captured["req"].get_header("Authorization") is None + + def test_open_url_no_auth_when_no_config(self, monkeypatch): + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + self._set_config(monkeypatch, []) + captured = {} + def fake_urlopen(req, timeout=None): + captured["req"] = req + resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + return resp + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen): + open_url("https://github.com/org/repo") + assert captured["req"].get_header("Authorization") is None + + def test_open_url_falls_through_on_401(self, monkeypatch): + import urllib.error + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + monkeypatch.setenv("GH_TOKEN", "bad-token") + self._set_config(monkeypatch, [_github_entry()]) + call_count = 0 + def fake_side_effect(req, timeout=None): + nonlocal call_count; call_count += 1 + if call_count == 1: + raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None) + resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False) + return resp + mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \ + patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect): + open_url("https://github.com/org/repo") + assert call_count == 2 + + +# --------------------------------------------------------------------------- +# open_url — negative tests +# --------------------------------------------------------------------------- + + +class TestAuthenticatedHttpNegative: + def _set_config(self, monkeypatch, entries): + from specify_cli.authentication import http as _mod + monkeypatch.setattr(_mod, "_config_override", entries) + + def test_500_raises_immediately(self, monkeypatch): + import urllib.error + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + monkeypatch.setenv("GH_TOKEN", "tok") + self._set_config(monkeypatch, [_github_entry()]) + mock_opener = MagicMock() + mock_opener.open.side_effect = urllib.error.HTTPError("url", 500, "ISE", {}, None) + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + with pytest.raises(urllib.error.HTTPError, match="500"): + open_url("https://github.com/org/repo") + + def test_404_raises_immediately(self, monkeypatch): + import urllib.error + from unittest.mock import MagicMock, patch + from specify_cli.authentication.http import open_url + monkeypatch.setenv("GH_TOKEN", "tok") + self._set_config(monkeypatch, [_github_entry()]) + mock_opener = MagicMock() + mock_opener.open.side_effect = urllib.error.HTTPError("url", 404, "Not Found", {}, None) + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + with pytest.raises(urllib.error.HTTPError, match="404"): + open_url("https://github.com/org/repo") + + def test_urlerror_propagates(self, monkeypatch): + import urllib.error + from unittest.mock import patch + from specify_cli.authentication.http import open_url + self._set_config(monkeypatch, []) + with patch("specify_cli.authentication.http.urllib.request.urlopen", + side_effect=urllib.error.URLError("refused")): + with pytest.raises(urllib.error.URLError): + open_url("https://example.com/file") + + def test_timeout_propagates(self, monkeypatch): + import socket + from unittest.mock import patch + from specify_cli.authentication.http import open_url + self._set_config(monkeypatch, []) + with patch("specify_cli.authentication.http.urllib.request.urlopen", + side_effect=socket.timeout("timed out")): + with pytest.raises(socket.timeout): + open_url("https://example.com/file") + + +# --------------------------------------------------------------------------- +# _load_config caching +# --------------------------------------------------------------------------- + + +class TestLoadConfigCaching: + def test_config_cached_after_first_load(self, monkeypatch): + """_load_config() should call load_auth_config only once per process.""" + from unittest.mock import patch + from specify_cli.authentication import http as _mod + from specify_cli.authentication.config import AuthConfigEntry + # Allow the real load path (no override) + monkeypatch.setattr(_mod, "_config_override", None) + monkeypatch.setattr(_mod, "_config_cache", None) + + entry = _github_entry() + call_count = 0 + + def fake_load(path=None): + nonlocal call_count + call_count += 1 + return [entry] + + with patch.object(_mod, "load_auth_config", side_effect=fake_load): + _mod._load_config() + _mod._load_config() + _mod._load_config() + + assert call_count == 1 + + def test_cache_bypassed_by_override(self, monkeypatch): + """When _config_override is set, the cache is ignored entirely.""" + from specify_cli.authentication import http as _mod + sentinel = [_github_entry()] + monkeypatch.setattr(_mod, "_config_override", sentinel) + monkeypatch.setattr(_mod, "_config_cache", None) + + result = _mod._load_config() + assert result is sentinel + # Cache must not have been populated when override is active + assert _mod._config_cache is None + + def test_failed_load_warns_once_and_caches_empty(self, monkeypatch): + """A bad auth.json emits exactly one warning and subsequent calls use cache.""" + from unittest.mock import patch + from specify_cli.authentication import http as _mod + import warnings as _warnings + monkeypatch.setattr(_mod, "_config_override", None) + monkeypatch.setattr(_mod, "_config_cache", None) + + call_count = 0 + + def fail_load(path=None): + nonlocal call_count + call_count += 1 + raise ValueError("bad config") + + with patch.object(_mod, "load_auth_config", side_effect=fail_load): + with _warnings.catch_warnings(record=True) as w: + _warnings.simplefilter("always") + result1 = _mod._load_config() + result2 = _mod._load_config() + result3 = _mod._load_config() + + user_warnings = [x for x in w if issubclass(x.category, UserWarning)] + assert len(user_warnings) == 1, "Expected exactly one warning" + # Loader called only once — subsequent calls used cache + assert call_count == 1 + # All calls returned the cached empty list + assert result1 == result2 == result3 == [] + + +# --------------------------------------------------------------------------- +# Redirect stripping +# --------------------------------------------------------------------------- + + +class TestRedirectStripping: + def test_redirect_within_hosts_preserves_auth(self): + from specify_cli.authentication.http import _StripAuthOnRedirect + from urllib.request import Request + import io + handler = _StripAuthOnRedirect(("github.com", "codeload.github.com")) + req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"}) + new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {}, + "https://codeload.github.com/org/repo/zip") + assert new_req is not None + auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization") + assert auth == "Bearer tok" + + def test_redirect_outside_hosts_strips_auth(self): + from specify_cli.authentication.http import _StripAuthOnRedirect + from urllib.request import Request + import io + handler = _StripAuthOnRedirect(("github.com",)) + req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"}) + new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {}, + "https://objects.githubusercontent.com/asset") + assert new_req is not None + assert new_req.headers.get("Authorization") is None + assert new_req.unredirected_hdrs.get("Authorization") is None + + def test_multi_hop_redirect_within_hosts_preserves_auth(self): + """Auth survives a multi-hop redirect chain within allowed hosts.""" + from specify_cli.authentication.http import _StripAuthOnRedirect + from urllib.request import Request + import io + hosts = ("github.com", "codeload.github.com", "objects-origin.githubusercontent.com") + handler = _StripAuthOnRedirect(hosts) + + # First hop: github.com → codeload.github.com + req1 = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"}) + req2 = handler.redirect_request(req1, io.BytesIO(b""), 302, "Found", {}, + "https://codeload.github.com/org/repo/zip") + assert req2 is not None + auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get("Authorization") + assert auth2 == "Bearer tok" + + # Second hop: codeload.github.com → objects-origin.githubusercontent.com + req3 = handler.redirect_request(req2, io.BytesIO(b""), 302, "Found", {}, + "https://objects-origin.githubusercontent.com/asset") + assert req3 is not None + auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization") + assert auth3 == "Bearer tok" + + +# --------------------------------------------------------------------------- +# _fetch_latest_release_tag delegation +# --------------------------------------------------------------------------- + + +class TestFetchLatestReleaseTagDelegation: + def _set_config(self, monkeypatch, entries): + from specify_cli.authentication import http as _mod + monkeypatch.setattr(_mod, "_config_override", entries) + + def _capture_request(self): + import json as _json + from unittest.mock import MagicMock + captured: dict = {} + def side_effect(req, timeout=None): + captured["request"] = req + body = _json.dumps({"tag_name": "v9.9.9"}).encode() + resp = MagicMock(); resp.read.return_value = body + cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False + return cm + return captured, side_effect + + def test_gh_token_forwarded_when_configured(self, monkeypatch): + from unittest.mock import MagicMock, patch + from specify_cli import _fetch_latest_release_tag + monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel") + self._set_config(monkeypatch, [_github_entry()]) + captured, side_effect = self._capture_request() + mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): + _fetch_latest_release_tag() + assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel" + + def test_no_config_means_no_auth(self, monkeypatch): + from unittest.mock import patch + from specify_cli import _fetch_latest_release_tag + self._set_config(monkeypatch, []) + captured, side_effect = self._capture_request() + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + assert captured["request"].get_header("Authorization") is None + + def test_accept_header_present(self, monkeypatch): + from unittest.mock import patch + from specify_cli import _fetch_latest_release_tag + self._set_config(monkeypatch, []) + captured, side_effect = self._capture_request() + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): + _fetch_latest_release_tag() + assert captured["request"].get_header("Accept") == "application/vnd.github+json" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index c5be0ab4f3..1434ba309d 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -2453,6 +2453,10 @@ def _make_catalog(self, temp_dir): (project_dir / ".specify").mkdir() return ExtensionCatalog(project_dir) + def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"): + from tests.auth_helpers import inject_github_config + inject_github_config(monkeypatch, token_env) + def test_make_request_no_token_no_auth_header(self, temp_dir, monkeypatch): """Without a token, requests carry no Authorization header.""" monkeypatch.delenv("GITHUB_TOKEN", raising=False) @@ -2473,6 +2477,7 @@ def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, temp_ """When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback.""" monkeypatch.setenv("GITHUB_TOKEN", " ") monkeypatch.setenv("GH_TOKEN", "ghp_fallback") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") catalog = self._make_catalog(temp_dir) req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") assert req.get_header("Authorization") == "Bearer ghp_fallback" @@ -2481,6 +2486,7 @@ def test_make_request_github_token_added_for_raw_githubusercontent(self, temp_di """GITHUB_TOKEN is attached for raw.githubusercontent.com URLs.""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") monkeypatch.delenv("GH_TOKEN", raising=False) + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = self._make_catalog(temp_dir) req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") assert req.get_header("Authorization") == "Bearer ghp_testtoken" @@ -2489,49 +2495,40 @@ def test_make_request_gh_token_fallback(self, temp_dir, monkeypatch): """GH_TOKEN is used when GITHUB_TOKEN is absent.""" monkeypatch.delenv("GITHUB_TOKEN", raising=False) monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") catalog = self._make_catalog(temp_dir) req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip") assert req.get_header("Authorization") == "Bearer ghp_ghtoken" - def test_make_request_github_token_takes_precedence_over_gh_token(self, temp_dir, monkeypatch): - """GITHUB_TOKEN takes precedence over GH_TOKEN when both are set.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary") - monkeypatch.setenv("GH_TOKEN", "ghp_secondary") + def test_make_request_gh_token_takes_precedence_over_github_token(self, temp_dir, monkeypatch): + """When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary") + monkeypatch.setenv("GH_TOKEN", "ghp_primary") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") catalog = self._make_catalog(temp_dir) req = catalog._make_request("https://api.github.com/repos/org/repo") assert req.get_header("Authorization") == "Bearer ghp_primary" - def test_make_request_token_not_added_for_non_github_url(self, temp_dir, monkeypatch): - """Auth header is never attached to non-GitHub URLs to prevent credential leakage.""" + def test_make_request_no_auth_for_non_matching_host(self, temp_dir, monkeypatch): + """Auth is NOT attached to hosts not listed in auth.json.""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = self._make_catalog(temp_dir) req = catalog._make_request("https://internal.example.com/catalog.json") assert "Authorization" not in req.headers - def test_make_request_token_not_added_for_github_lookalike_host(self, temp_dir, monkeypatch): - """Auth header is not attached to hosts that include github.com as a suffix.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") - catalog = self._make_catalog(temp_dir) - req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/ext.zip") - assert "Authorization" not in req.headers - - def test_make_request_token_not_added_for_github_in_path(self, temp_dir, monkeypatch): - """Auth header is not attached when github.com appears only in the URL path.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") - catalog = self._make_catalog(temp_dir) - req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/ext.zip") - assert "Authorization" not in req.headers - - def test_make_request_token_not_added_for_github_in_query(self, temp_dir, monkeypatch): - """Auth header is not attached when github.com appears only in the query string.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + def test_make_request_no_auth_when_no_config(self, temp_dir, monkeypatch): + """No auth header when no auth.json config exists.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) catalog = self._make_catalog(temp_dir) - req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/ext.zip") + req = catalog._make_request("https://github.com/org/repo/releases/download/v1/ext.zip") assert "Authorization" not in req.headers def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch): """GITHUB_TOKEN is attached for api.github.com URLs.""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = self._make_catalog(temp_dir) req = catalog._make_request("https://api.github.com/repos/org/repo/releases/assets/1") assert req.get_header("Authorization") == "Bearer ghp_testtoken" @@ -2539,49 +2536,17 @@ def test_make_request_token_added_for_api_github_com(self, temp_dir, monkeypatch def test_make_request_token_added_for_codeload_github_com(self, temp_dir, monkeypatch): """GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects).""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = self._make_catalog(temp_dir) req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0") assert req.get_header("Authorization") == "Bearer ghp_testtoken" - def test_redirect_preserves_auth_for_github_to_codeload(self): - """Auth header is preserved when GitHub redirects to codeload.github.com.""" - from specify_cli._github_http import _StripAuthOnRedirect - from urllib.request import Request - import io - - handler = _StripAuthOnRedirect() - original_url = "https://github.com/org/repo/archive/refs/tags/v1.zip" - redirect_url = "https://codeload.github.com/org/repo/zip/refs/tags/v1" - req = Request(original_url, headers={"Authorization": "Bearer ghp_test"}) - fp = io.BytesIO(b"") - new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url) - assert new_req is not None - auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization") - assert auth == "Bearer ghp_test" - - def test_redirect_strips_auth_for_github_to_external(self): - """Auth header is stripped when GitHub redirects to a non-GitHub host.""" - from specify_cli._github_http import _StripAuthOnRedirect - from urllib.request import Request - import io - - handler = _StripAuthOnRedirect() - original_url = "https://github.com/org/repo/releases/download/v1/asset.zip" - redirect_url = "https://objects.githubusercontent.com/github-production-release-asset/12345" - req = Request(original_url, headers={"Authorization": "Bearer ghp_test"}) - fp = io.BytesIO(b"") - new_req = handler.redirect_request(req, fp, 302, "Found", {}, redirect_url) - assert new_req is not None - auth_header = new_req.headers.get("Authorization") - auth_unredirected = new_req.unredirected_hdrs.get("Authorization") - assert auth_header is None - assert auth_unredirected is None - def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch): - """_fetch_single_catalog passes Authorization header via opener for GitHub URLs.""" + """_fetch_single_catalog passes Authorization header when a provider is configured.""" from unittest.mock import patch, MagicMock monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = self._make_catalog(temp_dir) catalog_data = {"schema_version": "1.0", "extensions": {}} @@ -2589,6 +2554,7 @@ def test_fetch_single_catalog_sends_auth_header(self, temp_dir, monkeypatch): mock_response.read.return_value = json.dumps(catalog_data).encode() mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/catalog.json" captured = {} mock_opener = MagicMock() @@ -2606,17 +2572,18 @@ def fake_open(req, timeout=None): install_allowed=True, ) - with patch("urllib.request.build_opener", return_value=mock_opener): + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): catalog._fetch_single_catalog(entry, force_refresh=True) assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch): - """download_extension passes Authorization header via opener for GitHub URLs.""" + """download_extension passes Authorization header when a provider is configured.""" from unittest.mock import patch, MagicMock import zipfile, io monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = self._make_catalog(temp_dir) # Build a minimal valid ZIP in memory @@ -2631,7 +2598,6 @@ def test_download_extension_sends_auth_header(self, temp_dir, monkeypatch): mock_response.__exit__ = MagicMock(return_value=False) captured = {} - mock_opener = MagicMock() def fake_open(req, timeout=None): @@ -2648,7 +2614,7 @@ def fake_open(req, timeout=None): } with patch.object(catalog, "get_extension_info", return_value=ext_info), \ - patch("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) assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" diff --git a/tests/test_presets.py b/tests/test_presets.py index 848c072dd0..e5143b834b 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1224,6 +1224,10 @@ def test_same_priority_sorted_alphabetically(self, project_dir): class TestPresetCatalog: """Test template catalog functionality.""" + def _inject_github_config(self, monkeypatch, token_env="GH_TOKEN"): + from tests.auth_helpers import inject_github_config + inject_github_config(monkeypatch, token_env) + def test_default_catalog_url(self, project_dir): """Test default catalog URL.""" catalog = PresetCatalog(project_dir) @@ -1418,6 +1422,7 @@ def test_make_request_whitespace_github_token_falls_back_to_gh_token(self, proje """When GITHUB_TOKEN is whitespace-only, GH_TOKEN is used as fallback.""" monkeypatch.setenv("GITHUB_TOKEN", " ") monkeypatch.setenv("GH_TOKEN", "ghp_fallback") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") assert req.get_header("Authorization") == "Bearer ghp_fallback" @@ -1426,6 +1431,7 @@ def test_make_request_github_token_added_for_github_url(self, project_dir, monke """GITHUB_TOKEN is attached for raw.githubusercontent.com URLs.""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") monkeypatch.delenv("GH_TOKEN", raising=False) + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://raw.githubusercontent.com/org/repo/main/catalog.json") assert req.get_header("Authorization") == "Bearer ghp_testtoken" @@ -1434,58 +1440,50 @@ def test_make_request_gh_token_fallback(self, project_dir, monkeypatch): """GH_TOKEN is used when GITHUB_TOKEN is absent.""" monkeypatch.delenv("GITHUB_TOKEN", raising=False) monkeypatch.setenv("GH_TOKEN", "ghp_ghtoken") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") assert req.get_header("Authorization") == "Bearer ghp_ghtoken" - def test_make_request_github_token_takes_precedence(self, project_dir, monkeypatch): - """GITHUB_TOKEN takes precedence over GH_TOKEN when both are set.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_primary") - monkeypatch.setenv("GH_TOKEN", "ghp_secondary") + def test_make_request_gh_token_takes_precedence(self, project_dir, monkeypatch): + """When auth.json uses GH_TOKEN, that token is used regardless of GITHUB_TOKEN.""" + monkeypatch.setenv("GITHUB_TOKEN", "ghp_secondary") + monkeypatch.setenv("GH_TOKEN", "ghp_primary") + self._inject_github_config(monkeypatch, token_env="GH_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://api.github.com/repos/org/repo") assert req.get_header("Authorization") == "Bearer ghp_primary" def test_make_request_token_added_for_codeload_github_com(self, project_dir, monkeypatch): - """GITHUB_TOKEN is attached for codeload.github.com URLs (GitHub archive redirects).""" + """GITHUB_TOKEN is attached for codeload.github.com URLs.""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://codeload.github.com/org/repo/zip/refs/tags/v1.0.0") assert req.get_header("Authorization") == "Bearer ghp_testtoken" - def test_make_request_token_not_added_for_non_github_url(self, project_dir, monkeypatch): - """Auth header is never attached to non-GitHub URLs to prevent credential leakage.""" + def test_make_request_no_auth_for_non_matching_host(self, project_dir, monkeypatch): + """Auth is NOT attached to hosts not listed in auth.json.""" monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) req = catalog._make_request("https://internal.example.com/catalog.json") assert "Authorization" not in req.headers - def test_make_request_token_not_added_for_github_lookalike_host(self, project_dir, monkeypatch): - """Auth header is not attached to hosts that include github.com as a suffix.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://github.com.evil.com/org/repo/releases/download/v1/pack.zip") - assert "Authorization" not in req.headers - - def test_make_request_token_not_added_for_github_in_path(self, project_dir, monkeypatch): - """Auth header is not attached when github.com appears only in the URL path.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") - catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://evil.example.com/github.com/org/repo/releases/download/v1/pack.zip") - assert "Authorization" not in req.headers - - def test_make_request_token_not_added_for_github_in_query(self, project_dir, monkeypatch): - """Auth header is not attached when github.com appears only in the query string.""" - monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + def test_make_request_no_auth_when_no_config(self, project_dir, monkeypatch): + """No auth header when no auth.json config exists.""" + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.delenv("GH_TOKEN", raising=False) catalog = PresetCatalog(project_dir) - req = catalog._make_request("https://evil.example.com/download?source=https://github.com/org/repo/v1/pack.zip") + req = catalog._make_request("https://github.com/org/repo/releases/download/v1/pack.zip") assert "Authorization" not in req.headers def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch): - """_fetch_single_catalog passes Authorization header via opener for GitHub URLs.""" + """_fetch_single_catalog passes Authorization header when configured.""" from unittest.mock import patch, MagicMock monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) catalog_data = {"schema_version": "1.0", "presets": {}} @@ -1493,6 +1491,7 @@ def test_fetch_single_catalog_sends_auth_header(self, project_dir, monkeypatch): mock_response.read.return_value = json.dumps(catalog_data).encode() mock_response.__enter__ = lambda s: s mock_response.__exit__ = MagicMock(return_value=False) + mock_response.geturl.return_value = "https://raw.githubusercontent.com/org/repo/main/presets/catalog.json" captured = {} mock_opener = MagicMock() @@ -1510,16 +1509,17 @@ def fake_open(req, timeout=None): install_allowed=True, ) - with patch("urllib.request.build_opener", return_value=mock_opener): + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): catalog._fetch_single_catalog(entry, force_refresh=True) assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" def test_download_pack_sends_auth_header(self, project_dir, monkeypatch): - """download_pack passes Authorization header via opener for GitHub URLs.""" + """download_pack passes Authorization header when configured.""" from unittest.mock import patch, MagicMock monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken") + self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") catalog = PresetCatalog(project_dir) import io @@ -1551,7 +1551,7 @@ def fake_open(req, timeout=None): } with patch.object(catalog, "get_pack_info", return_value=pack_info), \ - patch("urllib.request.build_opener", return_value=mock_opener): + patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): catalog.download_pack("test-pack", target_dir=project_dir) assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken" diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 28a0ce6414..7169c44df0 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -23,7 +23,6 @@ _normalize_tag, app, ) - from tests.conftest import strip_ansi runner = CliRunner() @@ -31,6 +30,10 @@ SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE" SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE" +_RATE_LIMITED_REASON = ( + "rate limited (configure ~/.specify/auth.json with a GitHub token)" +) + def _mock_urlopen_response(payload: dict) -> MagicMock: body = json.dumps(payload).encode("utf-8") @@ -66,11 +69,20 @@ def test_prints_exactly_three_lines_and_exits_zero(self): ] def test_stub_makes_no_network_call(self): - # If the stub ever starts calling urllib, this patch's side_effect - # would fire and the assertion below would fail. - with patch( - "specify_cli.urllib.request.urlopen", - side_effect=AssertionError("stub must not hit the network"), + # The stub must not hit the network via either urllib path: + # unauthenticated requests use urlopen() directly; authenticated ones + # go through build_opener(...).open(). Both are patched so that any + # accidental network call raises immediately. + network_error = AssertionError("stub must not hit the network") + with ( + patch( + "specify_cli.authentication.http.urllib.request.urlopen", + side_effect=network_error, + ), + patch( + "specify_cli.authentication.http.urllib.request.build_opener", + side_effect=network_error, + ), ): result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 @@ -138,7 +150,7 @@ def test_empty_string_passthrough(self): class TestUserStory1: def test_newer_available_prints_update_and_install_command(self): with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( - "specify_cli.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), ): result = runner.invoke(app, ["self", "check"]) @@ -151,7 +163,7 @@ def test_newer_available_prints_update_and_install_command(self): def test_up_to_date_prints_current_only(self): with patch("specify_cli._get_installed_version", return_value="0.9.0"), patch( - "specify_cli.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}), ): result = runner.invoke(app, ["self", "check"]) @@ -163,7 +175,7 @@ def test_up_to_date_prints_current_only(self): def test_dev_build_ahead_of_release_is_up_to_date(self): with patch("specify_cli._get_installed_version", return_value="0.7.5.dev0"), patch( - "specify_cli.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), ): result = runner.invoke(app, ["self", "check"]) @@ -174,7 +186,7 @@ def test_dev_build_ahead_of_release_is_up_to_date(self): def test_unknown_installed_still_prints_latest_and_reinstall(self): with patch("specify_cli._get_installed_version", return_value="unknown"), patch( - "specify_cli.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}), ): result = runner.invoke(app, ["self", "check"]) @@ -186,7 +198,7 @@ def test_unknown_installed_still_prints_latest_and_reinstall(self): def test_unparseable_tag_routes_to_indeterminate(self): with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( - "specify_cli.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", return_value=_mock_urlopen_response({"tag_name": "not-a-version"}), ): result = runner.invoke(app, ["self", "check"]) @@ -200,7 +212,7 @@ def test_unparseable_tag_routes_to_indeterminate(self): class TestFailureCategorization: def test_urlerror_maps_to_offline(self): with patch( - "specify_cli.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=urllib.error.URLError("no route to host"), ): tag, reason = _fetch_latest_release_tag() @@ -209,7 +221,7 @@ def test_urlerror_maps_to_offline(self): def test_timeout_maps_to_offline(self): with patch( - "specify_cli.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=TimeoutError(), ): tag, reason = _fetch_latest_release_tag() @@ -218,17 +230,17 @@ def test_timeout_maps_to_offline(self): def test_403_maps_to_rate_limited(self): with patch( - "specify_cli.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=_http_error(403, "rate limited"), ): tag, reason = _fetch_latest_release_tag() assert tag is None - assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)" + assert reason == _RATE_LIMITED_REASON @pytest.mark.parametrize("code", [404, 500, 502]) def test_other_http_uses_code_string(self, code): with patch( - "specify_cli.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=_http_error(code, "oops"), ): tag, reason = _fetch_latest_release_tag() @@ -238,7 +250,7 @@ def test_other_http_uses_code_string(self, code): def test_generic_exception_propagates(self): # Per research D-006, no catch-all exists; RuntimeError MUST bubble. with patch( - "specify_cli.urllib.request.urlopen", + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=RuntimeError("boom"), ): with pytest.raises(RuntimeError): @@ -247,7 +259,7 @@ def test_generic_exception_propagates(self): _FAILURE_CASES = [ ("offline or timeout", urllib.error.URLError("down")), - ("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)), + (_RATE_LIMITED_REASON, _http_error(403)), ("HTTP 500", _http_error(500)), ] @@ -258,22 +270,21 @@ def test_failure_prints_installed_plus_one_line_reason( self, expected_reason, side_effect ): with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( - "specify_cli.urllib.request.urlopen", side_effect=side_effect + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect ): result = runner.invoke(app, ["self", "check"]) output = strip_ansi(result.output) assert "Installed: 0.7.4" in output - if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)": + if expected_reason == _RATE_LIMITED_REASON: assert "Could not check latest release: rate limited" in output - assert "GH_TOKEN" in output - assert "GITHUB_TOKEN" in output + assert "~/.specify/auth.json" in output else: assert f"Could not check latest release: {expected_reason}" in output @pytest.mark.parametrize("_expected_reason, side_effect", _FAILURE_CASES) def test_failure_exits_zero(self, _expected_reason, side_effect): with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( - "specify_cli.urllib.request.urlopen", side_effect=side_effect + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect ): result = runner.invoke(app, ["self", "check"]) assert result.exit_code == 0 @@ -283,7 +294,7 @@ def test_failure_output_contains_no_traceback_no_url( self, _expected_reason, side_effect ): with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( - "specify_cli.urllib.request.urlopen", side_effect=side_effect + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect ): result = runner.invoke(app, ["self", "check"]) combined = (result.output or "") + (result.stderr or "") @@ -302,12 +313,20 @@ def _side_effect(req, timeout=None): return captured, _side_effect +def _inject_github_config(monkeypatch, token_env="GH_TOKEN"): + from tests.auth_helpers import inject_github_config + inject_github_config(monkeypatch, token_env) + + class TestUserStory3: def test_gh_token_attached_as_bearer_header(self, monkeypatch): monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) monkeypatch.delenv("GITHUB_TOKEN", raising=False) + _inject_github_config(monkeypatch, token_env="GH_TOKEN") captured, side_effect = _capture_request_via_urlopen() - with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + mock_opener = MagicMock() + mock_opener.open.side_effect = side_effect + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}" @@ -315,8 +334,11 @@ def test_gh_token_attached_as_bearer_header(self, monkeypatch): def test_github_token_used_when_gh_token_unset(self, monkeypatch): monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + _inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") captured, side_effect = _capture_request_via_urlopen() - with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + mock_opener = MagicMock() + mock_opener.open.side_effect = side_effect + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" @@ -325,7 +347,7 @@ def test_no_authorization_header_when_both_unset(self, monkeypatch): monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.delenv("GITHUB_TOKEN", raising=False) captured, side_effect = _capture_request_via_urlopen() - with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") is None @@ -333,8 +355,9 @@ def test_no_authorization_header_when_both_unset(self, monkeypatch): def test_empty_string_gh_token_treated_as_unset(self, monkeypatch): monkeypatch.setenv("GH_TOKEN", "") monkeypatch.delenv("GITHUB_TOKEN", raising=False) + _inject_github_config(monkeypatch, token_env="GH_TOKEN") captured, side_effect = _capture_request_via_urlopen() - with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") is None @@ -342,8 +365,9 @@ def test_empty_string_gh_token_treated_as_unset(self, monkeypatch): def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch): monkeypatch.setenv("GH_TOKEN", " ") monkeypatch.delenv("GITHUB_TOKEN", raising=False) + _inject_github_config(monkeypatch, token_env="GH_TOKEN") captured, side_effect = _capture_request_via_urlopen() - with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") is None @@ -351,8 +375,11 @@ def test_whitespace_only_gh_token_treated_as_unset(self, monkeypatch): def test_whitespace_only_gh_token_falls_back_to_github_token(self, monkeypatch): monkeypatch.setenv("GH_TOKEN", " ") monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) + _inject_github_config(monkeypatch, token_env="GITHUB_TOKEN") captured, side_effect = _capture_request_via_urlopen() - with patch("specify_cli.urllib.request.urlopen", side_effect=side_effect): + mock_opener = MagicMock() + mock_opener.open.side_effect = side_effect + with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener): _fetch_latest_release_tag() req = captured["request"] assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}" @@ -364,7 +391,7 @@ def test_gh_token_never_appears_in_failure_output( monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN) monkeypatch.delenv("GITHUB_TOKEN", raising=False) with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( - "specify_cli.urllib.request.urlopen", side_effect=side_effect + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect ): result = runner.invoke(app, ["self", "check"]) combined = strip_ansi((result.output or "") + (result.stderr or "")) @@ -377,7 +404,7 @@ def test_github_token_never_appears_in_failure_output( monkeypatch.delenv("GH_TOKEN", raising=False) monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN) with patch("specify_cli._get_installed_version", return_value="0.7.4"), patch( - "specify_cli.urllib.request.urlopen", side_effect=side_effect + "specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect ): result = runner.invoke(app, ["self", "check"]) combined = strip_ansi((result.output or "") + (result.stderr or "")) From abb5fe7090aa92fbbb581f1ca9fc53fd483198a2 Mon Sep 17 00:00:00 2001 From: Quratulain-bilal Date: Fri, 8 May 2026 00:40:40 +0500 Subject: [PATCH 184/184] feat(catalog): add API Evolve (api-evolve) community extension (#2479) * feat(catalog): add API Evolve (api-evolve) community extension * chore(catalog): refresh top-level updated_at --- README.md | 1 + extensions/catalog.community.json | 39 ++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dac427e4b2..79940074e4 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ The following community-contributed extensions are available in [`catalog.commun |-----------|---------|----------|--------|-----| | Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) | | AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) | +| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) | | Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) | | Architecture Guard | Continuous architecture governance for AI-assisted development. Reviews specs, plans, and code for architecture drift, producing structured refactor tasks and evolution proposals. | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) | | Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) | diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json index 449a0bf149..b9d72ce6e4 100644 --- a/extensions/catalog.community.json +++ b/extensions/catalog.community.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-05-07T05:51:00Z", + "updated_at": "2026-05-07T15:37:14Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json", "extensions": { "aide": { @@ -100,6 +100,43 @@ "created_at": "2026-05-04T00:00:00Z", "updated_at": "2026-05-04T00:00:00Z" }, + "api-evolve": { + "name": "API Evolve", + "id": "api-evolve", + "description": "Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC.", + "author": "Quratulain-bilal", + "version": "1.0.0", + "download_url": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/archive/refs/tags/v1.0.0.zip", + "repository": "https://github.com/Quratulain-bilal/spec-kit-api-evolve", + "homepage": "https://github.com/Quratulain-bilal/spec-kit-api-evolve", + "documentation": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/README.md", + "changelog": "https://github.com/Quratulain-bilal/spec-kit-api-evolve/blob/main/CHANGELOG.md", + "license": "MIT", + "requires": { + "speckit_version": ">=0.4.0" + }, + "provides": { + "commands": 12, + "hooks": 5 + }, + "tags": [ + "api", + "contracts", + "versioning", + "openapi", + "graphql", + "grpc", + "deprecation", + "breaking-changes", + "semver", + "governance" + ], + "verified": false, + "downloads": 0, + "stars": 0, + "created_at": "2026-05-07T00:00:00Z", + "updated_at": "2026-05-07T00:00:00Z" + }, "architect-preview": { "name": "Architect Impact Previewer", "id": "architect-preview",