mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9b868ad99 |
7
.github/workflows/test.yml
vendored
7
.github/workflows/test.yml
vendored
@@ -27,10 +27,9 @@ jobs:
|
||||
run: uvx ruff check src/
|
||||
|
||||
pytest:
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -47,9 +46,5 @@ 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
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -2,32 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [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
|
||||
|
||||
- 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
|
||||
|
||||
104
CONTRIBUTING.md
104
CONTRIBUTING.md
@@ -44,7 +44,8 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
|
||||
|
||||
@@ -68,99 +69,34 @@ 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 [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.
|
||||
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.
|
||||
|
||||
### Automated checks
|
||||
### Testing template and command changes locally
|
||||
|
||||
#### Agent configuration and wiring consistency
|
||||
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:
|
||||
|
||||
```bash
|
||||
uv run python -m pytest tests/test_agent_config_consistency.py -q
|
||||
```
|
||||
1. **Create release packages**
|
||||
|
||||
Run this when you change agent metadata, context update scripts, or integration wiring.
|
||||
Run the following command to generate the local packages:
|
||||
|
||||
### Manual testing
|
||||
```bash
|
||||
./.github/workflows/scripts/create-release-packages.sh v1.0.0
|
||||
```
|
||||
|
||||
#### Testing setup
|
||||
2. **Copy the relevant package to your test project**
|
||||
|
||||
```bash
|
||||
# Install the project and test dependencies from your local branch
|
||||
cd <spec-kit-repo>
|
||||
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.
|
||||
```bash
|
||||
cp -r .genreleases/sdd-copilot-package-sh/. <path-to-test-project>/
|
||||
```
|
||||
|
||||
# Initialize a test project using your local changes
|
||||
uv run specify init <temp-dir>/speckit-test --ai <agent> --offline
|
||||
cd <temp-dir>/speckit-test
|
||||
3. **Open and test the agent**
|
||||
|
||||
# Open in your agent
|
||||
```
|
||||
Navigate to your test project folder and open the agent to verify your implementation.
|
||||
|
||||
#### 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)
|
||||
~~~
|
||||
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.
|
||||
|
||||
## AI contributions in Spec Kit
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ 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, testing, and required development practices. |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process, review expectations, and required development practices. |
|
||||
| [TESTING.md](TESTING.md) | Validation strategy and testing procedures. |
|
||||
|
||||
**Main repository components:**
|
||||
|
||||
|
||||
15
README.md
15
README.md
@@ -50,8 +50,6 @@ 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):
|
||||
@@ -64,13 +62,7 @@ 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 verify the correct version is installed:
|
||||
|
||||
```bash
|
||||
specify version
|
||||
```
|
||||
|
||||
And use the tool directly:
|
||||
Then use the tool directly:
|
||||
|
||||
```bash
|
||||
# Create new project
|
||||
@@ -190,9 +182,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) |
|
||||
| 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) |
|
||||
@@ -238,7 +228,6 @@ 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-) |
|
||||
@@ -254,7 +243,6 @@ 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).
|
||||
|
||||
@@ -270,7 +258,6 @@ 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) |
|
||||
|
||||
133
TESTING.md
Normal file
133
TESTING.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 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 <spec-kit-repo>
|
||||
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 <spec-kit-repo>
|
||||
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 <agent> --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)
|
||||
~~~
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
## 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):
|
||||
@@ -71,14 +69,6 @@ uvx --from git+https://github.com/github/spec-kit.git@vX.Y.Z specify init <proje
|
||||
|
||||
## Verification
|
||||
|
||||
After installation, run the following command to confirm the correct version is installed:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
- `/speckit.specify` - Create specifications
|
||||
|
||||
@@ -128,14 +128,16 @@ 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 Issues
|
||||
## 9. Debug Network / TLS Skips
|
||||
|
||||
> **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`.
|
||||
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.)
|
||||
|
||||
## 10. Rapid Edit Loop Summary
|
||||
|
||||
@@ -164,7 +166,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 | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
|
||||
| TLS errors on corporate network | Try `--skip-tls` (not for production) |
|
||||
|
||||
## 13. Next Steps
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-14T21:30:00Z",
|
||||
"updated_at": "2026-04-13T14:39:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -36,70 +36,6 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -1593,50 +1529,6 @@
|
||||
"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",
|
||||
@@ -2085,38 +1977,6 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "[OK] Changes committed ${_phase} ${_command_name}" >&2
|
||||
echo "✓ Changes committed ${_phase} ${_command_name}" >&2
|
||||
|
||||
@@ -11,22 +11,10 @@ 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 raw="$1"
|
||||
local branch="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
@@ -35,20 +23,19 @@ 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
|
||||
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
|
||||
# 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
|
||||
|
||||
return 0
|
||||
# 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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -146,4 +146,4 @@ try {
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "[OK] Changes committed $phase $commandName"
|
||||
Write-Host "✓ Changes committed $phase $commandName"
|
||||
|
||||
@@ -15,14 +15,6 @@ function Test-HasGit {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SpecKitEffectiveBranchName {
|
||||
param([string]$Branch)
|
||||
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
||||
return $Matches[2]
|
||||
}
|
||||
return $Branch
|
||||
}
|
||||
|
||||
function Test-FeatureBranch {
|
||||
param(
|
||||
[string]$Branch,
|
||||
@@ -35,17 +27,24 @@ function Test-FeatureBranch {
|
||||
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}-') {
|
||||
[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")
|
||||
# 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
|
||||
}
|
||||
return $true
|
||||
|
||||
# Accept sequential (>=3 digits followed by hyphen) or timestamp (YYYYMMDD-HHMMSS-*)
|
||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
$isTimestamp = $Branch -match '^\d{8}-\d{6}-'
|
||||
|
||||
if ($isSequential -or $isTimestamp) {
|
||||
return $true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-13T00:00: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": {
|
||||
@@ -53,33 +53,6 @@
|
||||
"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",
|
||||
@@ -105,40 +78,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.7.1"
|
||||
version = "0.6.2"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -41,8 +41,6 @@ packages = ["src/specify_cli"]
|
||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||
# Bundled extensions (installable via `specify extension add <name>`)
|
||||
"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 <name>` or `specify init --preset <name>`)
|
||||
"presets/lean" = "specify_cli/core_pack/presets/lean"
|
||||
|
||||
|
||||
@@ -114,19 +114,8 @@ 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 raw="$1"
|
||||
local branch="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
@@ -135,9 +124,6 @@ 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
|
||||
@@ -145,7 +131,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: $raw" >&2
|
||||
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
@@ -153,12 +139,13 @@ 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
|
||||
branch_name=$(spec_kit_effective_branch_name "$2")
|
||||
local 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)
|
||||
|
||||
@@ -127,16 +127,6 @@ 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,
|
||||
@@ -148,69 +138,22 @@ 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}-') {
|
||||
[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")
|
||||
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"
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
# Resolve specs/<feature-dir> 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-FeatureDir {
|
||||
param([string]$RepoRoot, [string]$Branch)
|
||||
Join-Path $RepoRoot "specs/$Branch"
|
||||
}
|
||||
|
||||
function Get-FeaturePathsEnv {
|
||||
@@ -221,7 +164,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. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
|
||||
# 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
|
||||
@@ -230,24 +173,22 @@ function Get-FeaturePathsEnv {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
} elseif (Test-Path $featureJson) {
|
||||
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
|
||||
try {
|
||||
$featureConfig = $featureJsonRaw | ConvertFrom-Json
|
||||
} catch {
|
||||
[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
|
||||
$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
|
||||
}
|
||||
} else {
|
||||
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||
} catch {
|
||||
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
|
||||
}
|
||||
} else {
|
||||
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
|
||||
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
|
||||
@@ -33,7 +33,6 @@ import shutil
|
||||
import json
|
||||
import json5
|
||||
import stat
|
||||
import shlex
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -93,36 +92,6 @@ 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"
|
||||
@@ -652,31 +621,6 @@ 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/<id>/`` 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.
|
||||
|
||||
@@ -988,7 +932,6 @@ 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("--"):
|
||||
@@ -1027,10 +970,6 @@ 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)
|
||||
@@ -1220,7 +1159,6 @@ def init(
|
||||
("chmod", "Ensure scripts executable"),
|
||||
("constitution", "Constitution setup"),
|
||||
("git", "Install git extension"),
|
||||
("workflow", "Install bundled workflow"),
|
||||
("final", "Finalize"),
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
@@ -1324,37 +1262,6 @@ 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)
|
||||
|
||||
@@ -1464,16 +1371,6 @@ 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]")
|
||||
@@ -3318,10 +3215,6 @@ def extension_add(
|
||||
console.print("\n[green]✓[/green] Extension installed successfully!")
|
||||
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
|
||||
console.print(f" {manifest.description}")
|
||||
|
||||
for warning in manifest.warnings:
|
||||
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {warning}")
|
||||
|
||||
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
|
||||
for cmd in manifest.commands:
|
||||
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")
|
||||
@@ -3375,28 +3268,15 @@ def extension_remove(
|
||||
|
||||
# Get extension info for command and skill counts
|
||||
ext_manifest = manager.get_extension(extension_id)
|
||||
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
|
||||
reg_meta = manager.registry.get(extension_id)
|
||||
# Derive cmd_count from the registry's registered_commands (includes aliases)
|
||||
# rather than from the manifest (primary commands only). Use max() across
|
||||
# agents to get the per-agent count; sum() would double-count since users
|
||||
# think in logical commands, not per-agent file counts.
|
||||
# Use get() without a default so we can distinguish "key missing" (fall back
|
||||
# to manifest) from "key present but empty dict" (zero commands registered).
|
||||
registered_commands = reg_meta.get("registered_commands") if isinstance(reg_meta, dict) else None
|
||||
if isinstance(registered_commands, dict):
|
||||
cmd_count = max(
|
||||
(len(v) for v in registered_commands.values() if isinstance(v, list)),
|
||||
default=0,
|
||||
)
|
||||
else:
|
||||
cmd_count = len(ext_manifest.commands) if ext_manifest else 0
|
||||
raw_skills = reg_meta.get("registered_skills") if reg_meta else None
|
||||
skill_count = len(raw_skills) if isinstance(raw_skills, list) else 0
|
||||
|
||||
# Confirm removal
|
||||
if not force:
|
||||
console.print("\n[yellow]⚠ This will remove:[/yellow]")
|
||||
console.print(f" • {cmd_count} command{'s' if cmd_count != 1 else ''} per agent")
|
||||
console.print(f" • {cmd_count} commands from AI agent")
|
||||
if skill_count:
|
||||
console.print(f" • {skill_count} agent skill(s)")
|
||||
console.print(f" • Extension directory: .specify/extensions/{extension_id}/")
|
||||
@@ -4256,668 +4136,6 @@ 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 <workflow-id>[/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()
|
||||
|
||||
|
||||
@@ -317,6 +317,11 @@ class CommandRegistrar:
|
||||
"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
|
||||
@@ -655,15 +660,6 @@ class CommandRegistrar:
|
||||
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
if cmd_file.exists():
|
||||
cmd_file.unlink()
|
||||
# For SKILL.md agents each command lives in its own subdirectory
|
||||
# (e.g. .agents/skills/speckit-ext-cmd/SKILL.md). Remove the
|
||||
# parent dir when it becomes empty to avoid orphaned directories.
|
||||
parent = cmd_file.parent
|
||||
if parent != commands_dir and parent.exists():
|
||||
try:
|
||||
parent.rmdir() # no-op if dir still has other files
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if agent_name == "copilot":
|
||||
prompt_file = (
|
||||
|
||||
@@ -132,7 +132,6 @@ class ExtensionManifest:
|
||||
ValidationError: If manifest is invalid
|
||||
"""
|
||||
self.path = manifest_path
|
||||
self.warnings: List[str] = []
|
||||
self.data = self._load_yaml(manifest_path)
|
||||
self._validate()
|
||||
|
||||
@@ -218,98 +217,17 @@ class ExtensionManifest:
|
||||
f"Hook '{hook_name}' missing required 'command' field"
|
||||
)
|
||||
|
||||
# Validate commands; track renames so hook references can be rewritten.
|
||||
rename_map: Dict[str, str] = {}
|
||||
# Validate commands (if present)
|
||||
for cmd in commands:
|
||||
if not isinstance(cmd, dict):
|
||||
raise ValidationError(
|
||||
"Each command entry in 'provides.commands' must be a mapping"
|
||||
)
|
||||
if "name" not in cmd or "file" not in cmd:
|
||||
raise ValidationError("Command missing 'name' or 'file'")
|
||||
|
||||
# Validate command name format
|
||||
if not EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]):
|
||||
corrected = self._try_correct_command_name(cmd["name"], ext["id"])
|
||||
if corrected:
|
||||
self.warnings.append(
|
||||
f"Command name '{cmd['name']}' does not follow the required pattern "
|
||||
f"'speckit.{{extension}}.{{command}}'. Registering as '{corrected}'. "
|
||||
f"The extension author should update the manifest to use this name."
|
||||
)
|
||||
rename_map[cmd["name"]] = corrected
|
||||
cmd["name"] = corrected
|
||||
else:
|
||||
raise ValidationError(
|
||||
f"Invalid command name '{cmd['name']}': "
|
||||
"must follow pattern 'speckit.{extension}.{command}'"
|
||||
)
|
||||
|
||||
# Validate alias types; no pattern enforcement on aliases — they are
|
||||
# intentionally free-form to preserve community extension compatibility
|
||||
# (e.g. 'speckit.verify' short aliases used by existing extensions).
|
||||
aliases = cmd.get("aliases")
|
||||
if aliases is None:
|
||||
cmd["aliases"] = []
|
||||
aliases = []
|
||||
if not isinstance(aliases, list):
|
||||
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
|
||||
raise ValidationError(
|
||||
f"Aliases for command '{cmd['name']}' must be a list"
|
||||
f"Invalid command name '{cmd['name']}': "
|
||||
"must follow pattern 'speckit.{extension}.{command}'"
|
||||
)
|
||||
for alias in aliases:
|
||||
if not isinstance(alias, str):
|
||||
raise ValidationError(
|
||||
f"Aliases for command '{cmd['name']}' must be strings"
|
||||
)
|
||||
|
||||
# Rewrite any hook command references that pointed at a renamed command or
|
||||
# an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when
|
||||
# the reference is changed so extension authors know to update the manifest.
|
||||
for hook_name, hook_data in self.data.get("hooks", {}).items():
|
||||
if not isinstance(hook_data, dict):
|
||||
raise ValidationError(
|
||||
f"Hook '{hook_name}' must be a mapping, got {type(hook_data).__name__}"
|
||||
)
|
||||
command_ref = hook_data.get("command")
|
||||
if not isinstance(command_ref, str):
|
||||
continue
|
||||
# Step 1: apply any rename from the auto-correction pass.
|
||||
after_rename = rename_map.get(command_ref, command_ref)
|
||||
# Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'.
|
||||
parts = after_rename.split(".")
|
||||
if len(parts) == 2 and parts[0] == ext["id"]:
|
||||
final_ref = f"speckit.{ext['id']}.{parts[1]}"
|
||||
else:
|
||||
final_ref = after_rename
|
||||
if final_ref != command_ref:
|
||||
hook_data["command"] = final_ref
|
||||
self.warnings.append(
|
||||
f"Hook '{hook_name}' referenced command '{command_ref}'; "
|
||||
f"updated to canonical form '{final_ref}'. "
|
||||
f"The extension author should update the manifest."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _try_correct_command_name(name: str, ext_id: str) -> 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:
|
||||
@@ -850,7 +768,6 @@ class ExtensionManager:
|
||||
|
||||
from . import load_init_options
|
||||
from .agents import CommandRegistrar
|
||||
from .integrations import get_integration
|
||||
import yaml
|
||||
|
||||
written: List[str] = []
|
||||
@@ -861,7 +778,6 @@ class ExtensionManager:
|
||||
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"]
|
||||
@@ -941,10 +857,6 @@ class ExtensionManager:
|
||||
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)
|
||||
|
||||
@@ -91,123 +91,6 @@ class IntegrationBase(ABC):
|
||||
"""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:
|
||||
@@ -583,22 +466,6 @@ 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,
|
||||
@@ -667,22 +534,6 @@ 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"
|
||||
@@ -1057,22 +908,6 @@ class SkillsIntegration(IntegrationBase):
|
||||
``speckit-<name>/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.
|
||||
|
||||
@@ -1091,27 +926,6 @@ class SkillsIntegration(IntegrationBase):
|
||||
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-<stem>`` (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 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,
|
||||
|
||||
@@ -5,21 +5,11 @@ from __future__ import annotations
|
||||
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] = {
|
||||
@@ -158,43 +148,6 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
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,
|
||||
@@ -202,7 +155,7 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Claude skills, then inject Claude-specific flags and argument-hints."""
|
||||
"""Install Claude skills, then inject user-invocable, disable-model-invocation, and argument-hint."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
# Post-process generated skill files
|
||||
@@ -220,7 +173,11 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
updated = self.post_process_skill_content(content)
|
||||
# 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")
|
||||
|
||||
# Inject argument-hint if available for this skill
|
||||
skill_dir_name = path.parent.name # e.g. "speckit-plan"
|
||||
|
||||
@@ -28,21 +28,6 @@ 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 [
|
||||
|
||||
@@ -19,19 +19,14 @@ from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Integration for GitHub Copilot in VS Code."""
|
||||
|
||||
key = "copilot"
|
||||
config = {
|
||||
"name": "GitHub Copilot",
|
||||
"folder": ".github/",
|
||||
"commands_subdir": "agents",
|
||||
"install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
@@ -42,101 +37,6 @@ 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.<stem>`` instead of slash-commands.
|
||||
|
||||
Copilot ``.agent.md`` files are agents, not skills. The CLI
|
||||
selects them with ``--agent <name>`` 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"
|
||||
|
||||
@@ -707,7 +707,6 @@ class PresetManager:
|
||||
|
||||
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):
|
||||
@@ -717,7 +716,6 @@ class PresetManager:
|
||||
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
|
||||
@@ -791,10 +789,6 @@ class PresetManager:
|
||||
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")
|
||||
@@ -822,7 +816,6 @@ class PresetManager:
|
||||
|
||||
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"
|
||||
@@ -831,7 +824,6 @@ class PresetManager:
|
||||
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:
|
||||
@@ -885,10 +877,6 @@ class PresetManager:
|
||||
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
|
||||
|
||||
@@ -918,10 +906,6 @@ class PresetManager:
|
||||
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
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,132 +0,0 @@
|
||||
"""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.<id>.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
|
||||
@@ -1,540 +0,0 @@
|
||||
"""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}"
|
||||
@@ -1,778 +0,0 @@
|
||||
"""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)."""
|
||||
@@ -1,300 +0,0 @@
|
||||
"""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)
|
||||
@@ -1 +0,0 @@
|
||||
"""Auto-discovery for built-in step types."""
|
||||
@@ -1,155 +0,0 @@
|
||||
"""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
|
||||
@@ -1,61 +0,0 @@
|
||||
"""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
|
||||
@@ -1,61 +0,0 @@
|
||||
"""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
|
||||
@@ -1,58 +0,0 @@
|
||||
"""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
|
||||
@@ -1,121 +0,0 @@
|
||||
"""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
|
||||
@@ -1,55 +0,0 @@
|
||||
"""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
|
||||
@@ -1,156 +0,0 @@
|
||||
"""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
|
||||
@@ -1,75 +0,0 @@
|
||||
"""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
|
||||
@@ -1,70 +0,0 @@
|
||||
"""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
|
||||
@@ -1,68 +0,0 @@
|
||||
"""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
|
||||
@@ -1,68 +1,10 @@
|
||||
"""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)
|
||||
|
||||
@@ -18,8 +18,6 @@ from pathlib import Path
|
||||
|
||||
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"
|
||||
@@ -213,7 +211,6 @@ class TestGitExtensionInstall:
|
||||
# ── 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."""
|
||||
@@ -272,7 +269,6 @@ class TestInitializeRepoPowerShell:
|
||||
# ── 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."""
|
||||
@@ -380,7 +376,6 @@ class TestCreateFeaturePowerShell:
|
||||
# ── 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."""
|
||||
@@ -496,34 +491,6 @@ class TestAutoCommitBash:
|
||||
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:
|
||||
@@ -556,39 +523,10 @@ class TestAutoCommitPowerShell:
|
||||
)
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestGitCommonBash:
|
||||
def test_has_git_true(self, tmp_path: Path):
|
||||
"""has_git returns 0 in a git repo."""
|
||||
@@ -649,40 +587,3 @@ class TestGitCommonBash:
|
||||
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
|
||||
|
||||
@@ -5,14 +5,6 @@ import os
|
||||
|
||||
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):
|
||||
@@ -85,59 +77,6 @@ class TestInitIntegrationFlag:
|
||||
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
|
||||
|
||||
@@ -245,9 +245,6 @@ class MarkdownIntegrationTests:
|
||||
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):
|
||||
|
||||
@@ -347,11 +347,6 @@ class SkillsIntegrationTests:
|
||||
".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):
|
||||
|
||||
@@ -505,9 +505,6 @@ class TomlIntegrationTests:
|
||||
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):
|
||||
|
||||
@@ -384,9 +384,6 @@ class YamlIntegrationTests:
|
||||
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):
|
||||
|
||||
@@ -59,7 +59,7 @@ class TestClaudeIntegration:
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed["name"] == "speckit-plan"
|
||||
assert parsed["user-invocable"] is True
|
||||
assert parsed["disable-model-invocation"] is False
|
||||
assert parsed["disable-model-invocation"] is True
|
||||
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
|
||||
|
||||
def test_setup_installs_update_context_scripts(self, tmp_path):
|
||||
@@ -179,7 +179,7 @@ class TestClaudeIntegration:
|
||||
assert skill_file.exists()
|
||||
skill_content = skill_file.read_text(encoding="utf-8")
|
||||
assert "user-invocable: true" in skill_content
|
||||
assert "disable-model-invocation: false" in skill_content
|
||||
assert "disable-model-invocation: true" in skill_content
|
||||
|
||||
init_options = json.loads(
|
||||
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
|
||||
@@ -280,7 +280,7 @@ class TestClaudeIntegration:
|
||||
assert "preset:claude-skill-command" in content
|
||||
assert "name: speckit-research" in content
|
||||
assert "user-invocable: true" in content
|
||||
assert "disable-model-invocation: false" in content
|
||||
assert "disable-model-invocation: true" in content
|
||||
|
||||
metadata = manager.registry.get("claude-skill-command")
|
||||
assert "speckit-research" in metadata.get("registered_skills", [])
|
||||
@@ -400,115 +400,3 @@ class TestClaudeArgumentHints:
|
||||
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
|
||||
|
||||
@@ -199,8 +199,6 @@ class TestCopilotIntegration:
|
||||
".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"
|
||||
@@ -261,8 +259,6 @@ class TestCopilotIntegration:
|
||||
".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"
|
||||
|
||||
@@ -248,8 +248,6 @@ class TestGenericIntegration:
|
||||
".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"
|
||||
@@ -306,8 +304,6 @@ class TestGenericIntegration:
|
||||
".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"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -42,9 +41,8 @@ class TestManifestPathTraversal:
|
||||
|
||||
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(abs_path, "bad")
|
||||
m.record_file("/tmp/escape.txt", "bad")
|
||||
|
||||
def test_record_existing_rejects_parent_traversal(self, tmp_path):
|
||||
escape = tmp_path.parent / "escape.txt"
|
||||
|
||||
@@ -12,8 +12,6 @@ import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
SCRIPT_PATH = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
os.pardir,
|
||||
@@ -75,7 +73,6 @@ class TestScriptFrontmatterPattern:
|
||||
|
||||
|
||||
@requires_git
|
||||
@requires_bash
|
||||
class TestCursorFrontmatterIntegration:
|
||||
"""Integration tests using a real git repo."""
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ class TestExtensionSkillRegistration:
|
||||
assert isinstance(parsed, dict)
|
||||
assert parsed["name"] == "speckit-test-ext-hello"
|
||||
assert "description" in parsed
|
||||
assert parsed["disable-model-invocation"] is False
|
||||
assert parsed["disable-model-invocation"] is True
|
||||
|
||||
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
|
||||
"""No skills should be created when ai_skills is false."""
|
||||
|
||||
@@ -11,7 +11,6 @@ Tests cover:
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import platform
|
||||
import tempfile
|
||||
import shutil
|
||||
import tomllib
|
||||
@@ -244,7 +243,7 @@ class TestExtensionManifest:
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_invalid_command_name(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with command name that cannot be auto-corrected raises ValidationError."""
|
||||
"""Test manifest with invalid command name format."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"][0]["name"] = "invalid-name"
|
||||
@@ -256,83 +255,6 @@ class TestExtensionManifest:
|
||||
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
|
||||
@@ -395,19 +317,6 @@ class TestExtensionManifest:
|
||||
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"
|
||||
@@ -777,8 +686,8 @@ class TestExtensionManager:
|
||||
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_free_form_alias(self, temp_dir, project_dir):
|
||||
"""Aliases are free-form — a short 'speckit.shortcut' alias must be preserved unchanged."""
|
||||
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"
|
||||
@@ -809,10 +718,8 @@ class TestExtensionManager:
|
||||
(ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
assert manifest.commands[0]["aliases"] == ["speckit.shortcut"]
|
||||
assert manifest.warnings == []
|
||||
# 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."""
|
||||
@@ -1453,7 +1360,6 @@ scripts:
|
||||
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}
|
||||
@@ -1475,12 +1381,8 @@ Then {AGENT_SCRIPT}
|
||||
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
|
||||
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
|
||||
@@ -1717,54 +1619,6 @@ Then {AGENT_SCRIPT}
|
||||
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 =====
|
||||
|
||||
@@ -3999,58 +3853,3 @@ class TestHookInvocationRendering:
|
||||
assert "Executing: `/<missing command>`" in message
|
||||
assert "EXECUTE_COMMAND: <missing command>" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /<missing command>" 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
|
||||
|
||||
@@ -1175,7 +1175,8 @@ class TestPresetCatalog:
|
||||
"""Test search with cached catalog data."""
|
||||
from unittest.mock import patch
|
||||
|
||||
monkeypatch.delenv("SPECKIT_PRESET_CATALOG_URL", raising=False)
|
||||
# Only use the default catalog to prevent fetching the community catalog from the network
|
||||
monkeypatch.setenv("SPECKIT_PRESET_CATALOG_URL", PresetCatalog.DEFAULT_CATALOG_URL)
|
||||
catalog = PresetCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1975,7 +1976,7 @@ class TestPresetSkills:
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text()
|
||||
assert "preset:self-test" in content, "Skill should reference preset source"
|
||||
assert "disable-model-invocation: false" in content
|
||||
assert "disable-model-invocation: true" in content
|
||||
|
||||
# Verify it was recorded in registry
|
||||
metadata = manager.registry.get("self-test")
|
||||
@@ -2057,7 +2058,7 @@ class TestPresetSkills:
|
||||
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: false" in content
|
||||
assert "disable-model-invocation: true" 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."""
|
||||
|
||||
@@ -13,8 +13,6 @@ from pathlib import Path
|
||||
|
||||
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"
|
||||
@@ -28,13 +26,6 @@ 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"
|
||||
|
||||
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:
|
||||
@@ -151,7 +142,6 @@ 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."""
|
||||
@@ -197,7 +187,6 @@ class TestTimestampBranch:
|
||||
# ── 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."""
|
||||
@@ -236,8 +225,6 @@ class TestSequentialBranch:
|
||||
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")
|
||||
@@ -248,7 +235,6 @@ class TestSequentialBranchPowerShell:
|
||||
# ── check_feature_branch Tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestCheckFeatureBranch:
|
||||
def test_accepts_timestamp_branch(self):
|
||||
"""Test 6: check_feature_branch accepts timestamp branch."""
|
||||
@@ -285,35 +271,10 @@ class TestCheckFeatureBranch:
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestFindFeatureDirByPrefix:
|
||||
def test_timestamp_branch(self, tmp_path: Path):
|
||||
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
|
||||
@@ -342,73 +303,10 @@ class TestFindFeatureDirByPrefix:
|
||||
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:
|
||||
@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()
|
||||
(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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestGetCurrentBranch:
|
||||
def test_env_var(self):
|
||||
"""Test 12: get_current_branch returns SPECIFY_FEATURE env var."""
|
||||
@@ -419,7 +317,6 @@ class TestGetCurrentBranch:
|
||||
# ── 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."""
|
||||
@@ -433,7 +330,6 @@ class TestNoGitTimestamp:
|
||||
# ── E2E Flow Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestE2EFlow:
|
||||
def test_e2e_timestamp(self, git_repo: Path):
|
||||
"""Test 14: E2E timestamp flow — branch, dir, validation."""
|
||||
@@ -467,7 +363,6 @@ class TestE2EFlow:
|
||||
# ── 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."""
|
||||
@@ -668,7 +563,6 @@ class TestGitExtensionParity:
|
||||
# ── 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."""
|
||||
@@ -897,6 +791,15 @@ class TestDryRun:
|
||||
# ── 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"
|
||||
@@ -998,7 +901,6 @@ class TestPowerShellDryRun:
|
||||
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestGitBranchNameOverrideBash:
|
||||
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh."""
|
||||
|
||||
@@ -1103,7 +1005,6 @@ class TestGitBranchNameOverridePowerShell:
|
||||
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"
|
||||
@@ -1126,7 +1027,6 @@ class TestFeatureDirectoryResolution:
|
||||
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"
|
||||
@@ -1134,7 +1034,7 @@ class TestFeatureDirectoryResolution:
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
json.dumps({"feature_directory": str(custom_dir)}) + "\n",
|
||||
f'{{"feature_directory": "{custom_dir}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -1153,7 +1053,6 @@ class TestFeatureDirectoryResolution:
|
||||
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"
|
||||
@@ -1163,7 +1062,7 @@ class TestFeatureDirectoryResolution:
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
json.dumps({"feature_directory": str(json_dir)}) + "\n",
|
||||
f'{{"feature_directory": "{json_dir}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -1183,7 +1082,6 @@ class TestFeatureDirectoryResolution:
|
||||
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)
|
||||
@@ -1238,7 +1136,7 @@ class TestFeatureDirectoryResolution:
|
||||
|
||||
feature_json = git_repo / ".specify" / "feature.json"
|
||||
feature_json.write_text(
|
||||
json.dumps({"feature_directory": str(custom_dir)}) + "\n",
|
||||
f'{{"feature_directory": "{custom_dir}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,211 +0,0 @@
|
||||
# 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 <run_id>`, 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 <id>` installs from catalog, it downloads the workflow YAML from the catalog entry's `url` field into `.specify/workflows/<id>/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
|
||||
```
|
||||
@@ -1,285 +0,0 @@
|
||||
# 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 <run_id>
|
||||
|
||||
# Check run status
|
||||
specify workflow status <run_id>
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -1,339 +0,0 @@
|
||||
# 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 <run_id>
|
||||
|
||||
# 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/<run_id>/`:
|
||||
|
||||
```bash
|
||||
# List all runs with status
|
||||
specify workflow status
|
||||
|
||||
# Check a specific run
|
||||
specify workflow status <run_id>
|
||||
|
||||
# Resume a paused run (after approving a gate)
|
||||
specify workflow resume <run_id>
|
||||
|
||||
# Resume a failed run (retries from the failed step)
|
||||
specify workflow resume <run_id>
|
||||
```
|
||||
|
||||
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 <index>
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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 }}"
|
||||
Reference in New Issue
Block a user