Compare commits

...

25 Commits

Author SHA1 Message Date
github-actions[bot]
9db39a915b chore: bump version to 0.7.0 2026-04-14 18:04:25 +00:00
Copilot
a00e679918 Add workflow engine with catalog system (#2158)
* Initial plan

* Add workflow engine with step registry, expression engine, catalog system, and CLI commands

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/72a7bb5d-071f-4d67-a507-7e1abae2384d

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* Add comprehensive tests for workflow engine (94 tests)

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/72a7bb5d-071f-4d67-a507-7e1abae2384d

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* Address review feedback: do-while condition preservation and URL scheme validation

Agent-Logs-Url: https://github.com/github/spec-kit/sessions/72a7bb5d-071f-4d67-a507-7e1abae2384d

Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>

* Address review feedback, add CLI dispatch, interactive gates, and docs

Review comments (7/7):
- Add explanatory comment to empty except block
- Implement workflow catalog download with cleanup on failure
- Add input type coercion for number/boolean/enum
- Fix example workflow to remove non-existent output references
- Fix while_loop and if_then condition defaults (string 'false' → bool False)
- Fix resume step index tracking with step_offset parameter

CLI dispatch:
- Add build_exec_args() and dispatch_command() to IntegrationBase
- Override for Claude (skills: /speckit-specify), Gemini (-m flag),
  Codex (codex exec), Copilot (--agent speckit.specify)
- CommandStep invokes installed commands by name via integration CLI
- Add PromptStep for arbitrary inline prompts (10th step type)
- Stream CLI output live to terminal (no silent blocking)
- Remove timeout when streaming (user can Ctrl+C)
- Ctrl+C saves state as PAUSED for clean resume

Interactive gates:
- Gate steps prompt [1] approve [2] reject in TTY
- Fall back to PAUSED in non-interactive environments
- Resume re-executes the gate for interactive prompting

Documentation:
- workflows/README.md — user guide
- workflows/ARCHITECTURE.md — internals with Mermaid diagrams
- workflows/PUBLISHING.md — catalog submission guide

Tests: 94 → 122 workflow tests, 1362 total (all passing)

* Fix ruff lint errors: unused imports, f-string placeholders, undefined name

* Address second review: registry-backed validation, shell failures, loop/fan-out execution, URL validation

- VALID_STEP_TYPES now queries STEP_REGISTRY dynamically
- Shell step returns FAILED on non-zero exit code
- Persist workflow YAML in run directory for reliable resume
- Resume loads from run copy, falls back to installed workflow
- Engine iterates while/do-while loops up to max_iterations
- Engine expands fan-out per item with context.item
- HTTPS URL validation for catalog workflow installs (HTTP allowed for localhost)
- Fix catalog merge priority docstring (lower number wins)
- Fix dispatch_command docstring (no build_exec_args_for_command)
- Gate on_reject=retry pauses for re-prompt on resume
- Update docs to 10 step types, add prompt step to tables and README

* Potential fix for pull request finding 'Empty except'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

* Address third review: fan-out IDs, catalog guards, shell coercion, docs

- Fan-out generates unique per-item step IDs and collects results
- Catalog merge skips non-dict workflow entries (malformed data guard)
- Shell step coerces run_cmd to str after expression evaluation
- urlopen timeout=30 for catalog workflow installs
- yaml.dump with sort_keys=False, allow_unicode=True for catalog configs
- Document streaming timeout as intentionally unbounded (user Ctrl+C)
- Document --allow-all-tools as required for non-interactive + future enhancement
- Update test docstring and PUBLISHING.md to 10 step types with prompt

* Validate final URL after redirects in catalog fetch

urlopen follows redirects, so validate the response URL against the
same HTTPS/localhost rules to prevent redirect-based downgrade attacks.

* Address fourth review: filter arg eval, tags normalization, install redirect check

- Filter arguments now evaluated via _evaluate_simple_expression() so
  default(42) returns int not string
- Tags normalized: non-list/non-string values handled gracefully
- Install URL redirect validation (same as catalog fetch)
- Remove unused 'skipped' variable in catalog config parsing
- Author 'github' → 'GitHub' in example workflow
- Document nested step resume limitation (re-runs parent step)

* Add explanatory comment to empty except ValueError block

* Address fifth review: expression parsing, fan-out output, URL install, gate options

- Move string literal parsing before operator detection in expressions
  so quoted strings with operators (e.g. 'a in b') are not mis-parsed
- Fan-out: remove max_concurrency from persisted output, fix docstring
  to reflect sequential execution
- workflow add: support URL sources with HTTPS/redirect validation,
  validate workflow ID is non-empty before writing files
- Deduplicate local install logic via _validate_and_install_local()
- Remove 'edit' gate option from speckit workflow (not implemented)

* Add comments to empty except ValueError blocks in URL install

* Address sixth review: operator precedence, fan_in cleanup, registry resilience, docs

- Fix or/and operator precedence (or parsed first = lower precedence)
- Restore context.fan_in after fan-in step completes
- Catch JSONDecodeError in registry load for corrupted files
- Replace print() with on_step_start callback (library-safe)
- Gate validation warns when on_reject set but no reject option
- Shell step: document shell=True security tradeoff
- README: sdd-pipeline → speckit, parallel → sequential for fan-out
- ARCHITECTURE.md: parallel → fan-out/fan-in in diagram

* Address seventh review: string literal before pipe, type annotations, validate on install

- Move string literal check above pipe filter parsing so 'a | b' works
- Fix type annotations: input_values list[str] | None, run_id str | None
- Run validate_workflow() before installing from local path/URL
- Remove duplicate string literal check from expression parser

* Address eighth review: fan-out namespaced IDs, early return, catalog validation

- Fan-out per-item step IDs use _fanout_{step_id}_{base}_{idx} namespace
  to avoid collisions with user-defined step IDs
- Early return after fan-out loop when state is paused/failed/aborted
- Catalog installs parse + validate downloaded YAML before registering,
  using definition metadata instead of catalog entry for registry

* Address ninth review: populate catalog, fix indentation, priority, README

- Add speckit workflow entry to catalog.json so it's discoverable
- Fix shell step output dict indentation
- Catalog add_catalog priority derived from max existing + 1
- README Quick Start clarified with install + local file examples

* Address tenth review: max_iterations validation, catalog config guard, version alignment

- Validate max_iterations is int >= 1 in while and do-while steps
- Guard add_catalog against corrupted config (non-dict/non-list)
- Align speckit_version requirement to >=0.6.1 (current package version)
- Fan-out template validation uses separate seen_ids set to avoid
  false duplication errors with user-defined step IDs

* Address eleventh review: command step fails without CLI, ID mismatch warning, state persistence

- Command step returns FAILED when CLI not installed (was silent COMPLETED)
- Catalog install warns on workflow ID vs catalog key mismatch
- Engine persists state.save() before returning on unknown step type
- Update tests to expect FAILED for command steps without CLI
- Integration tests use shell steps for CLI-independent execution

* Address twelfth review: type annotations, version examples, streaming docs, requires

- Fix workflow_search type annotations (str | None)
- PUBLISHING.md: speckit_version >=0.15.0 → >=0.6.1
- Document that exit_code is captured and referenceable by later steps
- Mark requires as declared-but-not-enforced (planned enhancement)
- Note full stdout/stderr capture as planned enhancement

* Enforce catalog key matches workflow ID (fail instead of warn)

* Bundle speckit workflow: auto-install during specify init

- Add workflows/speckit to pyproject.toml force-include for wheel builds
- Add _locate_bundled_workflow() helper (mirrors _locate_bundled_extension)
- Auto-install speckit workflow during specify init (after git extension)
- Update all integration file inventory tests to expect workflow files

* Address fourteenth review: prompt fails without CLI, resolved step data, fan-out normalization

- PromptStep returns FAILED when CLI not installed (was silent COMPLETED)
- Engine step_data prefers resolved values from step output
- Fan-out normalizes output.results=[] for empty item lists
- subprocess.run inherits stdout/stderr (no explicit sys.stdout)
- Registry tests use issubset for extensibility

* Address fifteenth review: fan_in docstring, gate defaults, validation guards, reserved prefix

- FanInStep docstring: aggregate-only, no blocking semantics
- FanInStep: guard output_config as dict, handle None
- Gate validate: use same default options as execute
- Validate inputs is dict and steps is list before iterating
- Reserve _fanout_ prefix in step ID validation
- PUBLISHING.md: remove unenforced checklist items, add _fanout_ note

* Address sixteenth review: docs regex, fan_in try/finally, hyphenated dot-path keys

- PUBLISHING.md: update ID regex docs to match implementation (single-char OK)
- FanInStep: wrap expression evaluation in try/finally for context.fan_in
- Expression dot-path: allow hyphens in keys before list index (e.g. run-tests[0])

* Make speckit workflow integration-agnostic, document Copilot CLI requirement

- Workflow integration selectable via input (default: claude)
- Each command step uses {{ inputs.integration }} instead of hardcoded copilot
- Copilot docstring documents CLI requirement for workflow dispatch
- Added install_url for Copilot CLI docs

* Address seventeenth review: project checks, catalog robustness

- Add .specify/ project check to workflow run/resume/status/search/info
- remove_catalog validates config shape (dict + list) before indexing
- _fetch_single_catalog validates response is a dict
- _get_merged_workflows raises when all catalogs fail to fetch
- add_catalog guards against non-dict catalog entries in config

* Address eighteenth review: condition coercion, gate abort result, while default, cache guard, resume log

- evaluate_condition treats plain 'false'/'true' strings as booleans
- Gate abort returns StepResult(FAILED) instead of raising exception
  so step output is persisted in state for inspection
- while_loop max_iterations optional (default 10), validation aligned
- Catalog cache fallback catches invalid JSON gracefully
- resume() appends workflow_finished log entry like execute()

* Address nineteenth review: allow-all-tools opt-in, empty catalogs, abort dead code, while docstring

- --allow-all-tools controlled by SPECKIT_ALLOW_ALL_TOOLS env var (default: 1)
  Set to 0 to disable automatic tool approval for Copilot CLI
- Empty catalogs list falls back to built-in defaults (not an error)
- Remove unreachable WorkflowAbortError catches from execute/resume
  (gate abort now returns StepResult(FAILED) instead of raising)
- while_loop docstring updated: max_iterations is optional (default 10)

* Address twentieth review: gate abort maps to ABORTED status, do-while max_iterations optional

- Engine detects output.aborted from gate step and sets RunStatus.ABORTED
  (was unreachable — gate abort returned FAILED but status was always FAILED)
- do-while max_iterations now optional (default 10), aligned with while_loop
- do-while docstring and validation updated accordingly

* Coerce default_options to dict, align bundled workflow ID regex with validator

* Gate validates string options, prompt uses resolved integration, loop normalizes max_iterations

* Use parentId:childId convention for nested step IDs

- Fan-out per-item IDs use parentId:templateId:index (e.g. parallel:impl:0)
- Reserve ':' in user step IDs (validation rejects)
- Replaces _fanout_ prefix with cleaner namespacing
- Expressions like {{ steps.parallel:impl:0.output.file }} work naturally

* Validate workflow version is semantic versioning (X.Y.Z)

* Schema version validation, strict semver, load_workflow docstring, preserve max_concurrency

- Validate schema_version is '1.0' (reject unknown future schemas)
- Strict semver regex: ^\d+\.\d+\.\d+$ (rejects 1.0.0beta etc.)
- load_workflow docstring: 'parsed' not 'validated'
- Keep max_concurrency in fan-out output (was dropped)
- do_while docstring: engine re-evaluates step_config condition
- ARCHITECTURE.md: document nested resume limitation

* Path traversal prevention, loop step ID namespacing

- RunState validates run_id is alphanumeric+hyphens (no path separators)
- workflow_add validates catalog source doesn't escape workflows_dir
- Loop iterations namespace nested step IDs as parentId:childId:iteration
  so multiple iterations don't overwrite each other in context/state

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-14 10:11:56 -05:00
Rafa Gomes
c0152e4f3d docs(catalog): add claude-ask-questions to community preset catalog (#2191)
* feat: add claude-ask-questions preset for AskUserQuestion rendering

Delivers the /speckit.clarify and /speckit.checklist AskUserQuestion
integration as a stackable preset under presets/claude-ask-questions/
instead of modifying core templates or ClaudeIntegration.

- presets/claude-ask-questions/preset.yml registers command overrides
  for speckit.clarify and speckit.checklist following the same pattern
  as the bundled lean preset.
- Override commands replace the Markdown-table question-rendering
  blocks with AskUserQuestion instructions. Option | Description maps
  to {label, description} for clarify; Option | Candidate | Why It
  Matters maps to {label: Candidate, description: Why It Matters} for
  checklist. Recommended option is placed first with a
  "Recommended — <reasoning>" prefix; a final "Custom"/"Short" option
  preserves the free-form ≤5-word escape hatch.
- Registered in presets/catalog.json as a bundled preset.

Core templates, ClaudeIntegration, and the existing test suite are
left untouched, so non-Claude agents and users who do not install
this preset see no behavior change.

Closes github/spec-kit#2181

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move claude-ask-questions preset to external repo

Per maintainer feedback on #2191, presets should be hosted on the
author's own GitHub repository and registered in catalog.community.json
rather than bundled in spec-kit. Removes the bundled preset directory
and its entry from the official catalog, and adds a community catalog
entry pointing at the external repository and release archive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(catalog): sync claude-ask-questions description with upstream preset

* revert: keep presets/catalog.json updated_at unchanged

No entries in the official catalog changed in this PR, so the timestamp
bump was spurious. Addresses Copilot review feedback on #2191.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 08:17:28 -05:00
ysumanth06
03a9163633 Add SFSpeckit — Salesforce SDD Extension (#2208)
* Add SFSpeckit — Salesforce SDD Extension

* chore: update catalog updated_at timestamp
2026-04-13 18:12:27 -05:00
Gabriel Henrique
4687c33b0f feat(scripts): optional single-segment branch prefix for gitflow (#2202)
* feat(scripts): optional single-segment branch prefix for gitflow

- Add spec_kit_effective_branch_name / Get-SpecKitEffectiveBranchName:
  when branch matches prefix/rest with exactly one slash, validate and
  resolve specs/ using only the rest (e.g. feat/001-my-feature).
- Wire into check_feature_branch, find_feature_dir_by_prefix (bash) and
  Test-FeatureBranch, Find-FeatureDirByPrefix + Get-FeaturePathsEnv (PS).
- Align git extension git-common with core validation; remove unused
  get_feature_dir / Get-FeatureDir helpers.
- Extend tests in test_timestamp_branches.py and test_git_extension.py.

Made-with: Cursor

* Update scripts/powershell/common.ps1

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(ps): align feature-dir resolution errors with bash (no throw under Stop)

Find-FeatureDirByPrefix: on ambiguous prefix matches, write errors to stderr
and return $null instead of throwing, matching find_feature_dir_by_prefix.

Get-FeaturePathsEnv: narrow try/catch to ConvertFrom-Json only; add
Get-FeatureDirFromBranchPrefixOrExit to mirror bash get_feature_paths
(stderr + exit 1) when prefix lookup fails, avoiding unhandled terminating
errors under $ErrorActionPreference = 'Stop' in check-prerequisites,
setup-plan, and update-agent-context.

Made-with: Cursor

* Update tests/test_timestamp_branches.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update extensions/git/scripts/powershell/git-common.ps1

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update scripts/powershell/common.ps1

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-13 18:11:17 -05:00
Manfred Riem
de93528fad chore: release 0.6.2, begin 0.6.3.dev0 development (#2205)
* chore: bump version to 0.6.2

* chore: begin 0.6.3.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 14:35:22 -05:00
dango85
bb7da09b65 Add Worktrees extension to community catalog (#2207)
- Extension ID: worktrees
- Version: 1.0.0
- Author: dango85
- Description: Default-on worktree isolation for parallel agents

Made-with: Cursor

Co-authored-by: Abishek Yadav <abiyadav@publicisgroupe.net>
2026-04-13 14:34:54 -05:00
adaumann
fe75a45627 feat: Update catalog.community.json for preset-fiction-book-writing (#2199)
* feat: Update catalog.community.json for preset-fiction-book-writing

* Add fiction-book-writing preset to community catalog

- Preset ID: fiction-book-writing
- Version: 1.3.0
- Author: Andreas Daumann
- Description: Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* doc: added fiction-book-writing preset link in README.md

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-04-13 13:04:40 -05:00
Abdullah Khan
aa85b2f166 feat: Register "What-if Analysis" community extension (#2182)
* feat: implement read-only what-if analysis command

* chore: polish what-if analysis (Claude hints + optional tasks)

* refactor: deliver what-if analysis as a standalone extension

* Move What-if extension to standalone repo and update community catalog

* Fix: Reorder whatif extension alphabetically in community catalog
2026-04-13 12:01:37 -05:00
Fatima367
e27896e681 feat: add GitHub Issues Integration to community catalog (#2188)
* feat: add GitHub Issues Integration to community catalog

Add GitHub Issues Integration extension to the community catalog and README.

Extension provides:
- /speckit.github-issues.import - Import GitHub Issue and generate spec.md
- /speckit.github-issues.sync - Keep specs updated with issue changes
- /speckit.github-issues.link - Add bidirectional traceability

Resolves #2175

* Modify created_at and updated_at timestamps

Updated timestamps for created_at and updated_at fields.

* Update updated_at date in catalog.community.json
2026-04-13 11:22:46 -05:00
Furkan Köykıran
b67b2856b1 feat(agents): add Goose AI agent support (#2015)
* feat(integrations): add YamlIntegration base class for YAML recipe agents

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* feat(integrations): add Goose integration subpackage with YAML recipe support

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* feat(integrations): register GooseIntegration in the integration registry

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* feat(agents): add YAML format support to CommandRegistrar for extension/preset commands

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* feat(scripts): add goose agent type to bash update-agent-context script

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* feat(scripts): add goose agent type to PowerShell update-agent-context script

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* docs(agents): add Goose to supported agents table and integration notes

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* docs(readme): add Goose to supported agents table

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* test(integrations): add YamlIntegrationTests base mixin for YAML agent testing

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* test(integrations): add Goose integration tests

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* test(consistency): add Goose consistency checks for config, registrar, and scripts

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* docs(agents): move Goose to YAML Format section in Command File Formats

Goose uses YAML recipes, not Markdown. Remove it from the Markdown Format
list and add a dedicated YAML Format subsection with a representative
recipe example showing prompt: | and {{args}} placeholders.

* refactor(agents): delegate render_yaml_command to YamlIntegration

Remove the duplicate header dict, yaml.safe_dump call, body indentation,
and _human_title logic from CommandRegistrar.render_yaml_command(). Delegate
to YamlIntegration._render_yaml() and _human_title() so YAML recipe output
stays consistent across the init-time generation and command-registration
code paths.

* fix(agents): guard alias output path against directory traversal

Validate that alias_file resolves within commands_dir before writing.
Uses the same resolve().relative_to() pattern already established in
extensions.py for ZIP path containment checks.

* docs(agents): add Goose to Multi-Agent Support comment list in update-agent-context.sh

* fix(agents): add goose to print_summary Usage line in bash context script

The print_summary() function listed all supported agents in its Usage
output but omitted goose, making it inconsistent with the header docs
and the error message in update_specific_agent().

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): add goose to Print-Summary Usage line in PowerShell context script

The Print-Summary function listed all supported agents in its Usage
output but omitted goose, making it inconsistent with the ValidateSet
and the header documentation.

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): normalize description and title types in YamlIntegration.setup()

YAML frontmatter can contain non-string types (null, list, int).
Add isinstance checks matching TomlIntegration._extract_description()
to ensure Goose recipes always receive valid string fields.

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): validate shared script exists before exec in Goose bash wrapper

Add Forge-style check that the shared update-agent-context.sh is
present and executable, producing a clear error instead of a cryptic
shell exec failure when the shared script is missing.

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): validate shared script exists before invoke in Goose PowerShell wrapper

Add Forge-style Test-Path check that the shared update-agent-context.ps1
exists, producing a clear error instead of a cryptic PowerShell failure
when the shared script is missing.

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): normalize title and description types in render_yaml_command()

Extension/preset frontmatter can contain non-string types. Add
isinstance checks matching the normalization in YamlIntegration.setup()
so both code paths produce valid Goose recipe fields.

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(agents): replace $ARGUMENTS with arg_placeholder in process_template()

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* test(agents): assert $ARGUMENTS absent from generated YAML recipes

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* test(agents): assert $ARGUMENTS absent from generated TOML commands

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

* fix(tests): rewrite docstring to avoid embedded triple-quote in TOML test

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>

---------

Signed-off-by: Furkan Köykıran <furkankoykiran@gmail.com>
2026-04-13 07:55:44 -05:00
Ben Lawson
52ed84d723 Update ralph extension to v1.0.1 in community catalog (#2192)
- Extension ID: ralph
- Version: 1.0.0 → 1.0.1
- Author: Rubiss
- Changes: Fixed bash task count bug, removed hardcoded model,
  added regression tests, CI workflow, and release pipeline.
- Release: https://github.com/Rubiss/spec-kit-ralph/releases/tag/v1.0.1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 07:42:08 -05:00
Manfred Riem
cdbea09e1a fix: skip docs deployment workflow on forks (#2171)
Add repository check to build and deploy jobs so they skip
with success on forks, avoiding failed Pages deployments for
contributors.
2026-04-10 17:57:47 -05:00
Manfred Riem
1cb794e516 chore: release 0.6.1, begin 0.6.2.dev0 development (#2162)
* chore: bump version to 0.6.1

* chore: begin 0.6.2.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-10 16:22:42 -05:00
Manfred Riem
43cb0fa7ab feat: add bundled lean preset with minimal workflow commands (#2161)
* feat: add bundled lean preset with minimal workflow commands

Add a lean preset that overrides the 5 core workflow commands (specify,
plan, tasks, implement, constitution) with minimal prompts that produce
exactly one artifact each — no extension hooks, no scripts, no git
branching, no templates.

Bundled preset infrastructure:
- Add _locate_bundled_preset() mirroring _locate_bundled_extension()
- Update 'specify init --preset' to try bundled -> catalog fallback
- Update 'specify preset add' to try bundled -> catalog fallback
- Add bundled guard in download_pack() for presets without download URLs
- Add lean to presets/catalog.json with 'bundled: true' marker
- Add lean to pyproject.toml force-include for wheel packaging
- Align error messages with bundled extension error pattern

Tests: 15 new tests (TestLeanPreset + TestBundledPresetLocator)

* refactor: address review — clean up unused imports, strengthen test assertions

- Remove unused MagicMock import and cache_dir setup in download test
- Assert 'bundled' and 'reinstall' in CLI error output (not just exit code)
- Mock catalog in missing-locally test for deterministic bundled error path
- Fix test versions to satisfy updated speckit_version >=0.6.0 requirement

* refactor: address review — fix constitution paths, add REINSTALL_COMMAND to presets.py

- Fix constitution path to .specify/memory/constitution.md in plan, tasks,
  implement commands (matching core command convention)
- Include REINSTALL_COMMAND in download_pack() bundled guard for consistent
  recovery instructions across bundled extensions and presets

* refactor: address review — explicit feature_directory paths, ZIP cleanup in finally

- Prefix spec.md/plan.md/tasks.md with <feature_directory>/ in plan, tasks,
  and implement commands so the agent doesn't operate on repo root by mistake
- Move ZIP unlink into finally block in init --preset path so cleanup runs
  even when install_from_zip raises (matching preset_add pattern)

* refactor: address review — replace Unicode em dashes with ASCII, fix grammar

- Replace all Unicode em dashes with ASCII hyphens in preset.yml and
  catalog.json to avoid decode errors on non-UTF-8 environments
- Fix grammar: 'store it in tasks.md' -> 'store them in tasks.md'

* refactor: address review - align task format between tasks and implement

- Remove undefined [P] marker from implement (lean uses sequential execution)
- Clarify checkbox update: 'change - [ ] to - [x]' instead of ambiguous '[X]'
- Simplify implement to execute tasks in order without parallel complexity

* refactor: address review - parse frontmatter instead of raw substring search

- Use CommandRegistrar.parse_frontmatter() to check for scripts/agent_scripts
  keys in YAML frontmatter instead of brittle 'scripts:' substring search
2026-04-10 16:18:06 -05:00
Quratulain-bilal
74e3f45aa9 Add Brownfield Bootstrap extension to community catalog (#2145)
- 4 commands: scan, bootstrap, validate, migrate for existing codebases
- 1 hook: after_init for auto-scanning project after spec-kit initialization
- Addresses community request in issue #1436 (30+ reactions)
2026-04-10 13:16:26 -05:00
Quratulain-bilal
97ea7cf6a0 Add CI Guard extension to community catalog (#2157)
* Add CI Guard extension to community catalog

Adds spec-kit-ci-guard: spec compliance gates for CI/CD pipelines.

5 commands:
- /speckit.ci.check — run all compliance checks with pass/fail
- /speckit.ci.report — generate requirement traceability matrix
- /speckit.ci.gate — configure merge gate rules and thresholds
- /speckit.ci.drift — detect bidirectional spec-to-code drift
- /speckit.ci.badge — generate spec compliance badges

2 hooks: before_implement, after_implement

Bridges the gap between SDD workflow and CI/CD enforcement.

* Fix updated_at to monotonically increase per Copilot review

Set to 2026-04-10T15:00:00Z (later than previous 2026-04-10T12:34:56Z).
2026-04-10 12:20:55 -05:00
Quratulain-bilal
f43b85096c Add SpecTest extension to community catalog (#2159)
* Add SpecTest extension to community catalog

Adds spec-kit-spectest: auto-generate test scaffolds from spec criteria.

4 commands:
- /speckit.test.generate — generate framework-native test scaffolds
- /speckit.test.coverage — map spec requirements to test coverage
- /speckit.test.gaps — find untested requirements with suggestions
- /speckit.test.plan — generate structured test plan documents

1 hook: after_implement (gap detection)

Bridges the spec-to-test gap in the SDD workflow.

* Update extensions/catalog.community.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix spectest created_at/updated_at to use current timestamp per Copilot review

Set both to 2026-04-10T16:00:00Z instead of midnight.

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 12:11:25 -05:00
Manfred Riem
d1b95c2f59 fix: bundled extensions should not have download URLs (#2155)
* fix: bundled extensions should not have download URLs (#2151)

- Remove selftest from default catalog (not a published extension)
- Replace download_url with 'bundled: true' flag for git extension
- Add bundled check in extension add flow with clear error message
  when bundled extension is missing from installed package
- Add bundled check in download_extension() with specific error
- Direct users to reinstall via uv with full GitHub URL
- Add 3 regression tests for bundled extension handling

* refactor: address review - move bundled check up-front, extract reinstall constant

- Move bundled check before download_url inspection in download_extension()
  so bundled extensions can never be downloaded even with a URL present
- Extract REINSTALL_COMMAND constant to avoid duplicated install strings

* fix: allow bundled extensions with download_url to be updated

Bundled extensions should only be blocked from download when they have
no download_url. If a newer version is published to the catalog with a
URL, users should be able to install it to get bug fixes.

Add test for bundled-with-URL download path.
2026-04-10 11:29:18 -05:00
Quratulain-bilal
8bb08ae1a0 Add PR Bridge extension to community catalog (#2148)
- 3 commands: generate PR descriptions, reviewer checklists, and change summaries
- 1 hook: after_implement for auto-generating PR description
- Closes the SDD workflow loop: specify → plan → tasks → implement → PR
2026-04-10 09:51:20 -05:00
Gabriel Henrique
5732de60d0 feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156)
Use SkillsIntegration so workflows ship as speckit-*/SKILL.md. Update
init next-steps, extension hook invocation, docs, and tests.

Made-with: Cursor
2026-04-10 09:35:19 -05:00
Quratulain-bilal
b6e19b49ec Add TinySpec extension to community catalog (#2147)
* Add TinySpec extension to community catalog

- 3 commands: tinyspec, implement, classify for lightweight single-file workflow
- 1 hook: before_specify for auto-classifying task complexity
- Addresses community request in issue #1174 (22+ reactions)

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-10 09:10:42 -05:00
Ismael
7f1e38491f chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146)
* chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1

*  Removed invalid aliases

* Change updated at
2026-04-10 07:32:48 -05:00
Wes Etheredge
bc0288832e Add Status Report extension to community catalog (#2123)
- Extension ID: status-report
- Version: 1.2.5
- Author: Open-Agent-Tools
- Description: Project status, feature progress, and next-action recommendations for spec-driven workflows

Co-authored-by: Unserious AI <121459476+unseriousAI@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 07:27:15 -05:00
Manfred Riem
e70495c2b8 chore: release 0.6.0, begin 0.6.1.dev0 development (#2144)
* chore: bump version to 0.6.0

* chore: begin 0.6.1.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-09 14:17:11 -05:00
70 changed files with 9085 additions and 287 deletions

View File

@@ -26,6 +26,7 @@ concurrency:
jobs:
# Build job
build:
if: github.repository == 'github/spec-kit'
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -56,6 +57,7 @@ jobs:
# Deploy job
deploy:
if: github.repository == 'github/spec-kit'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

View File

@@ -17,7 +17,7 @@ Each AI agent is a self-contained **integration subpackage** under `src/specify_
```
src/specify_cli/integrations/
├── __init__.py # INTEGRATION_REGISTRY + _register_builtins()
├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, SkillsIntegration
├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration
├── manifest.py # IntegrationManifest (file tracking)
├── claude/ # Example: SkillsIntegration subclass
│ ├── __init__.py # ClaudeIntegration class
@@ -48,6 +48,7 @@ The registry is the **single source of truth for Python integration metadata**.
|---|---|
| Standard markdown commands (`.md`) | `MarkdownIntegration` |
| TOML-format commands (`.toml`) | `TomlIntegration` |
| YAML recipe files (`.yaml`) | `YamlIntegration` |
| Skill directories (`speckit-<name>/SKILL.md`) | `SkillsIntegration` |
| Fully custom output (companion files, settings merge, etc.) | `IntegrationBase` directly |
@@ -343,16 +344,82 @@ Command content with {SCRIPT} and {{args}} placeholders.
"""
```
### YAML Format
Used by: Goose
```yaml
version: 1.0.0
title: "Command Title"
description: "Command description"
author:
contact: spec-kit
extensions:
- type: builtin
name: developer
activities:
- Spec-Driven Development
prompt: |
Command content with {SCRIPT} and {{args}} placeholders.
```
## Argument Patterns
Different agents use different argument placeholders. The placeholder used in command files is always taken from `registrar_config["args"]` for each integration — check there first when in doubt:
- **Markdown/prompt-based**: `$ARGUMENTS` (default for most markdown agents)
- **TOML-based**: `{{args}}` (e.g., Gemini)
- **YAML-based**: `{{args}}` (e.g., Goose)
- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`)
- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
## Special Processing Requirements
Some agents require custom processing beyond the standard template transformations:
### Copilot Integration
GitHub Copilot has unique requirements:
- Commands use `.agent.md` extension (not `.md`)
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
- Installs `.vscode/settings.json` with prompt file recommendations
- Context file lives at `.github/copilot-instructions.md`
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
1. Processes templates with `process_template()`
2. Generates companion `.prompt.md` files
3. Merges VS Code settings
### Forge Integration
Forge has special frontmatter and argument requirements:
- Uses `{{parameters}}` instead of `$ARGUMENTS`
- Strips `handoffs` frontmatter key (Forge-specific collaboration feature)
- Injects `name` field into frontmatter when missing
Implementation: Extends `MarkdownIntegration` with custom `setup()` method that:
1. Inherits standard template processing from `MarkdownIntegration`
2. Adds extra `$ARGUMENTS``{{parameters}}` replacement after template processing
3. Applies Forge-specific transformations via `_apply_forge_transformations()`
4. Strips `handoffs` frontmatter key
5. Injects missing `name` fields
6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` and lists `forge` in their usage/help text
### Goose Integration
Goose is a YAML-format agent using Block's recipe system:
- Uses `.goose/recipes/` directory for YAML recipe files
- Uses `{{args}}` argument placeholder
- Produces YAML with `prompt: |` block scalar for command content
Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
1. Processes templates through the standard placeholder pipeline
2. Extracts title and description from frontmatter
3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt)
4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping
5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge)
## Common Pitfalls
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.

View File

@@ -2,6 +2,58 @@
<!-- insert new changelog below this comment -->
## [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
- feat: Register "What-if Analysis" community extension (#2182)
- feat: add GitHub Issues Integration to community catalog (#2188)
- feat(agents): add Goose AI agent support (#2015)
- Update ralph extension to v1.0.1 in community catalog (#2192)
- fix: skip docs deployment workflow on forks (#2171)
- chore: release 0.6.1, begin 0.6.2.dev0 development (#2162)
## [0.6.1] - 2026-04-10
### Changed
- feat: add bundled lean preset with minimal workflow commands (#2161)
- Add Brownfield Bootstrap extension to community catalog (#2145)
- Add CI Guard extension to community catalog (#2157)
- Add SpecTest extension to community catalog (#2159)
- fix: bundled extensions should not have download URLs (#2155)
- Add PR Bridge extension to community catalog (#2148)
- feat(cursor-agent): migrate from .cursor/commands to .cursor/skills (#2156)
- Add TinySpec extension to community catalog (#2147)
- chore: bump spec-kit-verify to 1.0.3 and spec-kit-review to 1.0.1 (#2146)
- Add Status Report extension to community catalog (#2123)
- chore: release 0.6.0, begin 0.6.1.dev0 development (#2144)
## [0.6.0] - 2026-04-09
### Changed
- Add Bugfix Workflow community extension to catalog and README (#2135)
- Add Worktree Isolation extension to community catalog (#2143)
- Add multi-repo-branching preset to community catalog (#2139)
- Readme clarity (#2013)
- Rewrite AGENTS.md for integration architecture (#2119)
- docs: add SpecKit Companion to Community Friends section (#2140)
- feat: add memorylint extension to community catalog (#2138)
- chore: release 0.5.1, begin 0.5.2.dev0 development (#2137)
## [0.5.1] - 2026-04-08
### Changed

View File

@@ -186,8 +186,10 @@ The following community-contributed extensions are available in [`catalog.commun
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
@@ -197,6 +199,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
@@ -211,6 +214,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
| Product Forge | Full product lifecycle: research → product spec → SpecKit → implement → verify → test | `process` | Read+Write | [speckit-product-forge](https://github.com/VaiYav/speckit-product-forge) |
| Project Health Check | Diagnose a Spec Kit project and report health issues across structure, agents, features, scripts, extensions, and git | `visibility` | Read-only | [spec-kit-doctor](https://github.com/KhawarHabibKhan/spec-kit-doctor) |
@@ -224,17 +228,23 @@ 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-) |
| Spec Refine | Update specs in-place, propagate changes to plan and tasks, and diff impact across artifacts | `process` | Read+Write | [spec-kit-refine](https://github.com/Quratulain-bilal/spec-kit-refine) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| 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).
@@ -250,6 +260,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes, author voice sample or humanized AI prose. | 21 templates, 17 commands | — | [spec-kit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
@@ -304,6 +315,7 @@ Community projects that extend, visualize, or build on Spec Kit:
| [Cursor](https://cursor.sh/) | ✅ | |
| [Forge](https://forgecode.dev/) | ✅ | CLI tool: `forge` |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✅ | |
| [Goose](https://block.github.io/goose/) | ✅ | Uses YAML recipe format in `.goose/recipes/` with slash command support |
| [GitHub Copilot](https://code.visualstudio.com/) | ✅ | |
| [IBM Bob](https://www.ibm.com/products/bob) | ✅ | IDE-based agent with slash command support |
| [Jules](https://jules.google.com/) | ✅ | |
@@ -648,7 +660,7 @@ specify init . --force --ai claude
specify init --here --force --ai claude
```
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
```bash
specify init <project_name> --ai claude --ignore-agent-tools

View File

@@ -292,7 +292,7 @@ This tells Spec Kit which feature directory to use when creating specs, plans, a
```bash
ls -la .claude/commands/ # Claude Code
ls -la .gemini/commands/ # Gemini
ls -la .cursor/commands/ # Cursor
ls -la .cursor/skills/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
```

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-09T00:00:00Z",
"updated_at": "2026-04-13T23:01:30Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -138,6 +138,38 @@
"created_at": "2026-04-08T00:00:00Z",
"updated_at": "2026-04-08T00:00:00Z"
},
"brownfield": {
"name": "Brownfield Bootstrap",
"id": "brownfield",
"description": "Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-brownfield/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-brownfield",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-brownfield",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-brownfield/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 4,
"hooks": 1
},
"tags": [
"brownfield",
"bootstrap",
"existing-project",
"migration",
"onboarding"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z"
},
"bugfix": {
"name": "Bugfix Workflow",
"id": "bugfix",
@@ -205,6 +237,39 @@
"created_at": "2026-03-29T00:00:00Z",
"updated_at": "2026-03-29T00:00:00Z"
},
"ci-guard": {
"name": "CI Guard",
"id": "ci-guard",
"description": "Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-ci-guard",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-ci-guard",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-ci-guard/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 5,
"hooks": 2
},
"tags": [
"ci-cd",
"compliance",
"governance",
"quality-gate",
"drift-detection",
"automation"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-10T17:00:00Z",
"updated_at": "2026-04-10T17:00:00Z"
},
"checkpoint": {
"name": "Checkpoint Extension",
"id": "checkpoint",
@@ -584,6 +649,46 @@
"created_at": "2026-03-06T00:00:00Z",
"updated_at": "2026-03-31T00:00:00Z"
},
"github-issues": {
"name": "GitHub Issues Integration",
"id": "github-issues",
"description": "Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability",
"author": "Fatima367",
"version": "1.0.0",
"download_url": "https://github.com/Fatima367/spec-kit-github-issues/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Fatima367/spec-kit-github-issues",
"homepage": "https://github.com/Fatima367/spec-kit-github-issues",
"documentation": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/README.md",
"changelog": "https://github.com/Fatima367/spec-kit-github-issues/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "gh",
"version": ">=2.0.0",
"required": true
}
]
},
"provides": {
"commands": 3,
"hooks": 0
},
"tags": [
"integration",
"github",
"issues",
"import",
"sync",
"traceability"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-12T15:30:00Z",
"updated_at": "2026-04-13T14:39:00Z"
},
"iterate": {
"name": "Iterate",
"id": "iterate",
@@ -1030,6 +1135,38 @@
"created_at": "2026-03-27T08:22:30Z",
"updated_at": "2026-03-27T08:22:30Z"
},
"pr-bridge": {
"name": "PR Bridge",
"id": "pr-bridge",
"description": "Auto-generate pull request descriptions, checklists, and summaries from spec artifacts.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-pr-bridge-/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"pull-request",
"automation",
"traceability",
"workflow",
"review"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z"
},
"presetify": {
"name": "Presetify",
"id": "presetify",
@@ -1128,8 +1265,8 @@
"id": "ralph",
"description": "Autonomous implementation loop using AI agent CLI.",
"author": "Rubiss",
"version": "1.0.0",
"download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.0.zip",
"version": "1.0.1",
"download_url": "https://github.com/Rubiss/spec-kit-ralph/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/Rubiss/spec-kit-ralph",
"homepage": "https://github.com/Rubiss/spec-kit-ralph",
"documentation": "https://github.com/Rubiss/spec-kit-ralph/blob/main/README.md",
@@ -1162,7 +1299,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-09T00:00:00Z",
"updated_at": "2026-03-09T00:00:00Z"
"updated_at": "2026-04-12T19:00:00Z"
},
"reconcile": {
"name": "Reconcile Extension",
@@ -1331,8 +1468,8 @@
"id": "review",
"description": "Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification.",
"author": "ismaelJimenez",
"version": "1.0.0",
"download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.0.zip",
"version": "1.0.1",
"download_url": "https://github.com/ismaelJimenez/spec-kit-review/archive/refs/tags/v1.0.1.zip",
"repository": "https://github.com/ismaelJimenez/spec-kit-review",
"homepage": "https://github.com/ismaelJimenez/spec-kit-review",
"documentation": "https://github.com/ismaelJimenez/spec-kit-review/blob/main/README.md",
@@ -1358,7 +1495,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-06T00:00:00Z",
"updated_at": "2026-03-06T00:00:00Z"
"updated_at": "2026-04-09T00:00:00Z"
},
"security-review": {
"name": "Security Review",
@@ -1392,6 +1529,50 @@
"created_at": "2026-04-03T03:24:03Z",
"updated_at": "2026-04-03T04:15:00Z"
},
"sf": {
"name": "SFSpeckit — Salesforce Spec-Driven Development",
"id": "sf",
"description": "Enterprise-Grade Spec-Driven Development (SDD) Framework for Salesforce.",
"author": "Sumanth Yanamala",
"version": "1.0.0",
"download_url": "https://github.com/ysumanth06/spec-kit-sf/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/ysumanth06/spec-kit-sf",
"homepage": "https://ysumanth06.github.io/spec-kit-sf/",
"documentation": "https://ysumanth06.github.io/spec-kit-sf/introduction.html",
"changelog": "https://github.com/ysumanth06/spec-kit-sf/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0",
"tools": [
{
"name": "sf",
"version": ">=2.0.0",
"required": true
},
{
"name": "gh",
"version": ">=2.0.0",
"required": false
}
]
},
"provides": {
"commands": 18,
"hooks": 2
},
"tags": [
"salesforce",
"enterprise",
"sdlc",
"apex",
"devops"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-13T22:11:30Z",
"updated_at": "2026-04-13T22:11:30Z"
},
"ship": {
"name": "Ship Release Extension",
"id": "ship",
@@ -1454,6 +1635,39 @@
"created_at": "2026-03-18T00:00:00Z",
"updated_at": "2026-03-18T00:00:00Z"
},
"spectest": {
"name": "SpecTest",
"id": "spectest",
"description": "Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-spectest/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-spectest",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-spectest",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-spectest/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 4,
"hooks": 1
},
"tags": [
"testing",
"test-generation",
"coverage",
"quality",
"automation",
"traceability"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-10T16:00:00Z",
"updated_at": "2026-04-10T16:00:00Z"
},
"staff-review": {
"name": "Staff Review Extension",
"id": "staff-review",
@@ -1516,6 +1730,36 @@
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
},
"status-report": {
"name": "Status Report",
"id": "status-report",
"description": "Project status, feature progress, and next-action recommendations for spec-driven workflows.",
"author": "Open-Agent-Tools",
"version": "1.2.5",
"download_url": "https://github.com/Open-Agent-Tools/spec-kit-status/archive/refs/tags/v1.2.5.zip",
"repository": "https://github.com/Open-Agent-Tools/spec-kit-status",
"homepage": "https://github.com/Open-Agent-Tools/spec-kit-status",
"documentation": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/README.md",
"changelog": "https://github.com/Open-Agent-Tools/spec-kit-status/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"workflow",
"project-management",
"status"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-08T15:05:14Z",
"updated_at": "2026-04-08T15:05:14Z"
},
"superb": {
"name": "Superpowers Bridge",
"id": "superb",
@@ -1591,6 +1835,38 @@
"created_at": "2026-03-02T00:00:00Z",
"updated_at": "2026-03-02T00:00:00Z"
},
"tinyspec": {
"name": "TinySpec",
"id": "tinyspec",
"description": "Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process.",
"author": "Quratulain-bilal",
"version": "1.0.0",
"download_url": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/Quratulain-bilal/spec-kit-tinyspec",
"homepage": "https://github.com/Quratulain-bilal/spec-kit-tinyspec",
"documentation": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/README.md",
"changelog": "https://github.com/Quratulain-bilal/spec-kit-tinyspec/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"lightweight",
"small-tasks",
"workflow",
"productivity",
"efficiency"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z"
},
"v-model": {
"name": "V-Model Extension Pack",
"id": "v-model",
@@ -1628,8 +1904,8 @@
"id": "verify",
"description": "Post-implementation quality gate that validates implemented code against specification artifacts.",
"author": "ismaelJimenez",
"version": "1.0.0",
"download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.0.zip",
"version": "1.0.3",
"download_url": "https://github.com/ismaelJimenez/spec-kit-verify/archive/refs/tags/v1.0.3.zip",
"repository": "https://github.com/ismaelJimenez/spec-kit-verify",
"homepage": "https://github.com/ismaelJimenez/spec-kit-verify",
"documentation": "https://github.com/ismaelJimenez/spec-kit-verify/blob/main/README.md",
@@ -1653,7 +1929,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-03T00:00:00Z",
"updated_at": "2026-03-03T00:00:00Z"
"updated_at": "2026-04-09T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",
@@ -1686,6 +1962,34 @@
"created_at": "2026-03-16T00:00:00Z",
"updated_at": "2026-03-16T00:00:00Z"
},
"whatif": {
"name": "What-if Analysis",
"id": "whatif",
"description": "Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them.",
"author": "DevAbdullah90",
"version": "1.0.0",
"repository": "https://github.com/DevAbdullah90/spec-kit-whatif",
"homepage": "https://github.com/DevAbdullah90/spec-kit-whatif",
"documentation": "https://github.com/DevAbdullah90/spec-kit-whatif/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.0"
},
"provides": {
"commands": 1,
"hooks": 0
},
"tags": [
"analysis",
"planning",
"simulation"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
},
"worktree": {
"name": "Worktree Isolation",
"id": "worktree",
@@ -1717,6 +2021,38 @@
"stars": 0,
"created_at": "2026-04-09T00:00:00Z",
"updated_at": "2026-04-09T00:00:00Z"
},
"worktrees": {
"name": "Worktrees",
"id": "worktrees",
"description": "Default-on worktree isolation for parallel agents — sibling or nested layout",
"author": "dango85",
"version": "1.0.0",
"download_url": "https://github.com/dango85/spec-kit-worktree-parallel/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/dango85/spec-kit-worktree-parallel",
"homepage": "https://github.com/dango85/spec-kit-worktree-parallel",
"documentation": "https://github.com/dango85/spec-kit-worktree-parallel/blob/main/README.md",
"changelog": "https://github.com/dango85/spec-kit-worktree-parallel/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"worktree",
"git",
"parallel",
"isolation",
"agents"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-06T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
"extensions": {
"git": {
@@ -10,27 +10,13 @@
"description": "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"download_url": "https://github.com/github/spec-kit/releases/download/ext-git-v1.0.0/git.zip",
"bundled": true,
"tags": [
"git",
"branching",
"workflow",
"core"
]
},
"selftest": {
"name": "Spec Kit Self-Test Utility",
"id": "selftest",
"version": "1.0.0",
"description": "Verifies catalog extensions by programmatically walking through the discovery, installation, and registration lifecycle.",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"download_url": "https://github.com/github/spec-kit/releases/download/selftest-v1.0.0/selftest.zip",
"tags": [
"testing",
"core",
"utility"
]
}
}
}

View File

@@ -11,10 +11,22 @@ has_git() {
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
spec_kit_effective_branch_name() {
local raw="$1"
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
else
printf '%s\n' "$raw"
fi
}
# Validate that a branch name matches the expected feature branch pattern.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
check_feature_branch() {
local branch="$1"
local raw="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
@@ -23,19 +35,20 @@ check_feature_branch() {
return 0
fi
# Reject malformed timestamps (7-digit date, 8-digit date without trailing slug, or 7-digit with slug)
if [[ "$branch" =~ ^[0-9]{7}-[0-9]{6} ]] || [[ "$branch" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
echo "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name" >&2
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
return 1
fi
# 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
return 0
}

View File

@@ -15,6 +15,14 @@ function Test-HasGit {
}
}
function Get-SpecKitEffectiveBranchName {
param([string]$Branch)
if ($Branch -match '^([^/]+)/([^/]+)$') {
return $Matches[2]
}
return $Branch
}
function Test-FeatureBranch {
param(
[string]$Branch,
@@ -27,24 +35,17 @@ function Test-FeatureBranch {
return $true
}
# Reject malformed timestamps (7-digit date or no trailing slug)
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or
($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
if ($hasMalformedTimestamp) {
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
Write-Output "Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name"
$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")
return $false
}
# 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
return $true
}

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-04-13T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"aide-in-place": {
@@ -53,6 +53,33 @@
"spec-first"
]
},
"claude-ask-questions": {
"name": "Claude AskUserQuestion",
"id": "claude-ask-questions",
"version": "1.0.0",
"description": "Upgrades /speckit.clarify and /speckit.checklist on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question.",
"author": "0xrafasec",
"repository": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions",
"download_url": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions/archive/refs/tags/v1.0.0.zip",
"homepage": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions",
"documentation": "https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.0"
},
"provides": {
"templates": 0,
"commands": 2
},
"tags": [
"claude",
"ask-user-question",
"clarify",
"checklist"
],
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
},
"explicit-task-dependencies": {
"name": "Explicit Task Dependencies",
"id": "explicit-task-dependencies",
@@ -78,6 +105,40 @@
"wave-dag"
]
},
"fiction-book-writing": {
"name": "Fiction Book Writing",
"id": "fiction-book-writing",
"version": "1.3.0",
"description": "Spec-Driven Development for novel and long-form fiction. Replaces software engineering terminology with storytelling craft: specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports 8 POV modes, all major plot structure frameworks, 5 humanized-AI prose profiles, and exports to DOCX/EPUB/LaTeX via pandoc.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.3.0.zip",
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.5.0"
},
"provides": {
"templates": 21,
"commands": 17,
"scripts": 1
},
"tags": [
"writing",
"novel",
"book",
"fiction",
"storytelling",
"creative-writing",
"kdp",
"single-pov",
"multi-pov",
"export"
],
"created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-04-09T08:00:00Z"
},
"multi-repo-branching": {
"name": "Multi-Repo Branching",
"id": "multi-repo-branching",

View File

@@ -1,6 +1,22 @@
{
"schema_version": "1.0",
"updated_at": "2026-03-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
"presets": {}
"presets": {
"lean": {
"name": "Lean Workflow",
"id": "lean",
"version": "1.0.0",
"description": "Minimal core workflow commands - just the prompt, just the artifact",
"author": "github",
"repository": "https://github.com/github/spec-kit",
"bundled": true,
"tags": [
"lean",
"minimal",
"workflow",
"core"
]
}
}
}

View File

@@ -0,0 +1,15 @@
---
description: Create or update the project constitution.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Create or update the project constitution and store it in `.specify/memory/constitution.md`.
- Project name, guiding principles, non-negotiable rules
- Derive from user input and existing repo context (README, docs)

View File

@@ -0,0 +1,22 @@
---
description: Execute the implementation plan by processing all tasks in tasks.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Read `.specify/feature.json` to get the feature directory path.
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md` and `<feature_directory>/plan.md` and `<feature_directory>/tasks.md`.
3. **Execute tasks** in order:
- Complete each task before moving to the next
- Mark completed tasks by changing `- [ ]` to `- [x]` in `<feature_directory>/tasks.md`
- Halt on failure and report the issue
4. **Validate**: Verify all tasks are completed and the implementation matches the spec.

View File

@@ -0,0 +1,19 @@
---
description: Create a plan and store it in plan.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Read `.specify/feature.json` to get the feature directory path.
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md`.
3. Create an implementation plan and store it in `<feature_directory>/plan.md`.
- Technical context: tech stack, dependencies, project structure
- Design decisions, architecture, file structure

View File

@@ -0,0 +1,23 @@
---
description: Create a specification and store it in spec.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. **Ask the user** for the feature directory path (e.g., `specs/my-feature`). Do not proceed until provided.
2. Create the directory and write `.specify/feature.json`:
```json
{ "feature_directory": "<feature_directory>" }
```
3. Create a specification from the user input and store it in `<feature_directory>/spec.md`.
- Overview, functional requirements, user scenarios, success criteria
- Every requirement must be testable
- Make informed defaults for unspecified details

View File

@@ -0,0 +1,19 @@
---
description: Create the tasks needed for implementation and store them in tasks.md.
---
## User Input
```text
$ARGUMENTS
```
## Outline
1. Read `.specify/feature.json` to get the feature directory path.
2. **Load context**: `.specify/memory/constitution.md` and `<feature_directory>/spec.md` and `<feature_directory>/plan.md`.
3. Create dependency-ordered implementation tasks and store them in `<feature_directory>/tasks.md`.
- Every task uses checklist format: `- [ ] [TaskID] Description with file path`
- Organized by phase: setup, foundational, user stories in priority order, polish

50
presets/lean/preset.yml Normal file
View File

@@ -0,0 +1,50 @@
schema_version: "1.0"
preset:
id: "lean"
name: "Lean Workflow"
version: "1.0.0"
description: "Minimal core workflow commands - just the prompt, just the artifact"
author: "github"
repository: "https://github.com/github/spec-kit"
license: "MIT"
requires:
speckit_version: ">=0.6.0"
provides:
templates:
- type: "command"
name: "speckit.specify"
file: "commands/speckit.specify.md"
description: "Lean specify - create spec.md from a feature description"
replaces: "speckit.specify"
- type: "command"
name: "speckit.plan"
file: "commands/speckit.plan.md"
description: "Lean plan - create plan.md from the spec"
replaces: "speckit.plan"
- type: "command"
name: "speckit.tasks"
file: "commands/speckit.tasks.md"
description: "Lean tasks - create tasks.md from plan and spec"
replaces: "speckit.tasks"
- type: "command"
name: "speckit.implement"
file: "commands/speckit.implement.md"
description: "Lean implement - execute tasks from tasks.md"
replaces: "speckit.implement"
- type: "command"
name: "speckit.constitution"
file: "commands/speckit.constitution.md"
description: "Lean constitution - create or update project constitution"
replaces: "speckit.constitution"
tags:
- "lean"
- "minimal"
- "workflow"

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.5.2.dev0"
version = "0.7.0"
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,6 +41,10 @@ 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"
[project.optional-dependencies]
test = [

View File

@@ -114,8 +114,19 @@ has_git() {
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
spec_kit_effective_branch_name() {
local raw="$1"
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
else
printf '%s\n' "$raw"
fi
}
check_feature_branch() {
local branch="$1"
local raw="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
@@ -124,6 +135,9 @@ check_feature_branch() {
return 0
fi
local branch
branch=$(spec_kit_effective_branch_name "$raw")
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
@@ -131,7 +145,7 @@ check_feature_branch() {
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
@@ -139,13 +153,12 @@ check_feature_branch() {
return 0
}
get_feature_dir() { echo "$1/specs/$2"; }
# Find feature directory by numeric prefix instead of exact branch match
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
find_feature_dir_by_prefix() {
local repo_root="$1"
local branch_name="$2"
local branch_name
branch_name=$(spec_kit_effective_branch_name "$2")
local specs_dir="$repo_root/specs"
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)

View File

@@ -30,12 +30,12 @@
#
# 5. Multi-Agent Support
# - Handles agent-specific file paths and naming conventions
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Antigravity or Generic
# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Goose, Antigravity or Generic
# - Can update single agents or all existing agent files
# - Creates default Claude file if no agent files exist
#
# Usage: ./update-agent-context.sh [agent_type]
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic
# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic
# Leave empty to update all existing agent files
set -e
@@ -74,7 +74,7 @@ AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md"
ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md"
CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md"
QODER_FILE="$REPO_ROOT/QODER.md"
# Amp, Kiro CLI, IBM Bob, Pi, and Forge all share AGENTS.md — use AGENTS_FILE to avoid
# Amp, Kiro CLI, IBM Bob, Pi, Forge, and Goose all share AGENTS.md — use AGENTS_FILE to avoid
# updating the same file multiple times.
AMP_FILE="$AGENTS_FILE"
SHAI_FILE="$REPO_ROOT/SHAI.md"
@@ -710,12 +710,15 @@ update_specific_agent() {
forge)
update_agent_file "$AGENTS_FILE" "Forge" || return 1
;;
goose)
update_agent_file "$AGENTS_FILE" "Goose" || return 1
;;
generic)
log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent."
;;
*)
log_error "Unknown agent type '$agent_type'"
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic"
log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic"
exit 1
;;
esac
@@ -759,7 +762,7 @@ update_all_existing_agents() {
_update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false
_update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false
_update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false
_update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge" || _all_ok=false
_update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose" || _all_ok=false
_update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false
_update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false
_update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false
@@ -800,7 +803,7 @@ print_summary() {
fi
echo
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]"
log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]"
}
#==============================================================================

View File

@@ -127,6 +127,16 @@ function Test-HasGit {
}
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
function Get-SpecKitEffectiveBranchName {
param([string]$Branch)
if ($Branch -match '^([^/]+)/([^/]+)$') {
return $Matches[2]
}
return $Branch
}
function Test-FeatureBranch {
param(
[string]$Branch,
@@ -138,22 +148,69 @@ function Test-FeatureBranch {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
return $true
}
$raw = $Branch
$Branch = Get-SpecKitEffectiveBranchName $raw
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
Write-Output "ERROR: Not on a feature branch. Current branch: $Branch"
Write-Output "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name"
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
return $false
}
return $true
}
function Get-FeatureDir {
param([string]$RepoRoot, [string]$Branch)
Join-Path $RepoRoot "specs/$Branch"
# Resolve specs/<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-FeaturePathsEnv {
@@ -164,7 +221,7 @@ function Get-FeaturePathsEnv {
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
# 3. Exact branch-to-directory mapping via Get-FeatureDir (legacy fallback)
# 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
$featureJson = Join-Path $repoRoot '.specify/feature.json'
if ($env:SPECIFY_FEATURE_DIRECTORY) {
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
@@ -173,22 +230,24 @@ function Get-FeaturePathsEnv {
$featureDir = Join-Path $repoRoot $featureDir
}
} elseif (Test-Path $featureJson) {
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
try {
$featureConfig = Get-Content $featureJson -Raw | ConvertFrom-Json
if ($featureConfig.feature_directory) {
$featureDir = $featureConfig.feature_directory
# Normalize relative paths to absolute under repo root
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
} else {
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
}
$featureConfig = $featureJsonRaw | ConvertFrom-Json
} catch {
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
[Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
exit 1
}
if ($featureConfig.feature_directory) {
$featureDir = $featureConfig.feature_directory
# Normalize relative paths to absolute under repo root
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
} else {
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
}
} else {
$featureDir = Get-FeatureDir -RepoRoot $repoRoot -Branch $currentBranch
$featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
}
[PSCustomObject]@{

View File

@@ -9,7 +9,7 @@ Mirrors the behavior of scripts/bash/update-agent-context.sh:
2. Plan Data Extraction
3. Agent File Management (create from template or update existing)
4. Content Generation (technology stack, recent changes, timestamp)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, generic)
5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, goose, generic)
.PARAMETER AgentType
Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist).
@@ -25,7 +25,7 @@ Relies on common helper functions in common.ps1
#>
param(
[Parameter(Position=0)]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','generic')]
[ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','goose','generic')]
[string]$AgentType
)
@@ -68,6 +68,7 @@ $KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md'
$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.md'
$IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md'
$FORGE_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$GOOSE_FILE = Join-Path $REPO_ROOT 'AGENTS.md'
$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md'
@@ -417,8 +418,9 @@ function Update-SpecificAgent {
'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' }
'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' }
'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' }
'goose' { Update-AgentFile -TargetFile $GOOSE_FILE -AgentName 'Goose' }
'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic'; return $false }
default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic'; return $false }
}
}
@@ -460,7 +462,7 @@ function Update-AllExistingAgents {
if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false }
if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false }
if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false }
if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge')) { $ok = $false }
if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose')) { $ok = $false }
if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false }
if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false }
if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false }
@@ -490,7 +492,7 @@ function Print-Summary {
if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" }
if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" }
Write-Host ''
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|generic]'
Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]'
}
function Main {

View File

@@ -621,6 +621,56 @@ 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.
Checks the wheel's core_pack first, then falls back to the
source-checkout ``presets/<id>/`` directory.
"""
import re as _re
if not _re.match(r'^[a-z0-9-]+$', preset_id):
return None
core = _locate_core_pack()
if core is not None:
candidate = core / "presets" / preset_id
if (candidate / "preset.yml").is_file():
return candidate
# Source-checkout / editable install: look relative to repo root
repo_root = Path(__file__).parent.parent.parent
candidate = repo_root / "presets" / preset_id
if (candidate / "preset.yml").is_file():
return candidate
return None
def _install_shared_infra(
project_path: Path,
script_type: str,
@@ -1134,6 +1184,7 @@ def init(
("chmod", "Ensure scripts executable"),
("constitution", "Constitution setup"),
("git", "Install git extension"),
("workflow", "Install bundled workflow"),
("final", "Finalize"),
]:
tracker.add(key, label)
@@ -1237,6 +1288,37 @@ def init(
else:
tracker.skip("git", "--no-git flag")
# Install bundled speckit workflow
try:
bundled_wf = _locate_bundled_workflow("speckit")
if bundled_wf:
from .workflows.catalog import WorkflowRegistry
from .workflows.engine import WorkflowDefinition
wf_registry = WorkflowRegistry(project_path)
if wf_registry.is_installed("speckit"):
tracker.complete("workflow", "already installed")
else:
import shutil as _shutil
dest_wf = project_path / ".specify" / "workflows" / "speckit"
dest_wf.mkdir(parents=True, exist_ok=True)
_shutil.copy2(
bundled_wf / "workflow.yml",
dest_wf / "workflow.yml",
)
definition = WorkflowDefinition.from_yaml(dest_wf / "workflow.yml")
wf_registry.add("speckit", {
"name": definition.name,
"version": definition.version,
"description": definition.description,
"source": "bundled",
})
tracker.complete("workflow", "speckit installed")
else:
tracker.skip("workflow", "bundled workflow not found")
except Exception as wf_err:
sanitized_wf = str(wf_err).replace('\n', ' ').strip()
tracker.error("workflow", f"install failed: {sanitized_wf[:120]}")
# Fix permissions after all installs (scripts + extensions)
ensure_executable_scripts(project_path, tracker=tracker)
@@ -1266,27 +1348,44 @@ def init(
preset_manager = PresetManager(project_path)
speckit_ver = get_speckit_version()
# Try local directory first, then catalog
# Try local directory first, then bundled, then catalog
local_path = Path(preset).resolve()
if local_path.is_dir() and (local_path / "preset.yml").exists():
preset_manager.install_from_directory(local_path, speckit_ver)
else:
preset_catalog = PresetCatalog(project_path)
pack_info = preset_catalog.get_pack_info(preset)
if not pack_info:
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
bundled_path = _locate_bundled_preset(preset)
if bundled_path:
preset_manager.install_from_directory(bundled_path, speckit_ver)
else:
try:
zip_path = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(zip_path, speckit_ver)
# Clean up downloaded ZIP to avoid cache accumulation
preset_catalog = PresetCatalog(project_path)
pack_info = preset_catalog.get_pack_info(preset)
if not pack_info:
console.print(f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping.")
elif pack_info.get("bundled") and not pack_info.get("download_url"):
from .extensions import REINSTALL_COMMAND
console.print(
f"[yellow]Warning:[/yellow] Preset '{preset}' is bundled with spec-kit "
f"but could not be found in the installed package."
)
console.print(
"This usually means the spec-kit installation is incomplete or corrupted."
)
console.print(f"Try reinstalling: {REINSTALL_COMMAND}")
else:
zip_path = None
try:
zip_path.unlink(missing_ok=True)
except OSError:
# Best-effort cleanup; failure to delete is non-fatal
pass
except PresetError as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
zip_path = preset_catalog.download_pack(preset)
preset_manager.install_from_zip(zip_path, speckit_ver)
except PresetError as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset '{preset}': {preset_err}")
finally:
if zip_path is not None:
# Clean up downloaded ZIP to avoid cache accumulation
try:
zip_path.unlink(missing_ok=True)
except OSError:
# Best-effort cleanup; failure to delete is non-fatal
pass
except Exception as preset_err:
console.print(f"[yellow]Warning:[/yellow] Failed to install preset: {preset_err}")
@@ -1338,7 +1437,7 @@ def init(
step_num = 2
# Determine skill display mode for the next-steps panel.
# Skills integrations (codex, kimi, agy, trae) should show skill invocation syntax.
# Skills integrations (codex, kimi, agy, trae, cursor-agent) should show skill invocation syntax.
from .integrations.base import SkillsIntegration as _SkillsInt
_is_skills_integration = isinstance(resolved_integration, _SkillsInt)
@@ -1347,7 +1446,8 @@ def init(
kimi_skill_mode = selected_ai == "kimi"
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
trae_skill_mode = selected_ai == "trae"
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode
if codex_skill_mode and not ai_skills:
# Integration path installed skills; show the helpful notice
@@ -1356,6 +1456,9 @@ def init(
if claude_skill_mode and not ai_skills:
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
step_num += 1
if cursor_agent_skill_mode and not ai_skills:
steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"
def _display_cmd(name: str) -> str:
@@ -1365,6 +1468,8 @@ def init(
return f"/speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"
if cursor_agent_skill_mode:
return f"/speckit-{name}"
return f"/speckit.{name}"
steps_lines.append(f"{step_num}. Start using {usage_label} with your AI agent:")
@@ -2134,28 +2239,50 @@ def preset_add(
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
elif pack_id:
catalog = PresetCatalog(project_root)
pack_info = catalog.get_pack_info(pack_id)
if not pack_info:
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
raise typer.Exit(1)
if not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
raise typer.Exit(1)
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
try:
zip_path = catalog.download_pack(pack_id)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
# Try bundled preset first, then catalog
bundled_path = _locate_bundled_preset(pack_id)
if bundled_path:
console.print(f"Installing bundled preset [cyan]{pack_id}[/cyan]...")
manifest = manager.install_from_directory(bundled_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
finally:
if 'zip_path' in locals() and zip_path.exists():
zip_path.unlink(missing_ok=True)
else:
catalog = PresetCatalog(project_root)
pack_info = catalog.get_pack_info(pack_id)
if not pack_info:
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in catalog")
raise typer.Exit(1)
# Bundled presets should have been caught above; if we reach
# here the bundled files are missing from the installation.
if pack_info.get("bundled") and not pack_info.get("download_url"):
from .extensions import REINSTALL_COMMAND
console.print(
f"[red]Error:[/red] Preset '{pack_id}' is bundled with spec-kit "
f"but could not be found in the installed package."
)
console.print(
"\nThis usually means the spec-kit installation is incomplete or corrupted."
)
console.print("Try reinstalling spec-kit:")
console.print(f" {REINSTALL_COMMAND}")
raise typer.Exit(1)
if not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
console.print(f"[red]Error:[/red] Preset '{pack_id}' is from the '{catalog_name}' catalog which is discovery-only (install not allowed).")
console.print("Add the catalog with --install-allowed or install from the preset's repository directly with --from.")
raise typer.Exit(1)
console.print(f"Installing preset [cyan]{pack_info.get('name', pack_id)}[/cyan]...")
try:
zip_path = catalog.download_pack(pack_id)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
finally:
if 'zip_path' in locals() and zip_path.exists():
zip_path.unlink(missing_ok=True)
else:
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
raise typer.Exit(1)
@@ -3001,7 +3128,7 @@ def extension_add(
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND
project_root = Path.cwd()
@@ -3103,6 +3230,19 @@ def extension_add(
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
if bundled_path is None:
# Bundled extensions without a download URL must come from the local package
if ext_info.get("bundled") and not ext_info.get("download_url"):
console.print(
f"[red]Error:[/red] Extension '{ext_info['id']}' is bundled with spec-kit "
f"but could not be found in the installed package."
)
console.print(
"\nThis usually means the spec-kit installation is incomplete or corrupted."
)
console.print("Try reinstalling spec-kit:")
console.print(f" {REINSTALL_COMMAND}")
raise typer.Exit(1)
# Enforce install_allowed policy
if not ext_info.get("_install_allowed", True):
catalog_name = ext_info.get("_catalog_name", "community")
@@ -4053,6 +4193,668 @@ def extension_set_priority(
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
# ===== Workflow Commands =====
workflow_app = typer.Typer(
name="workflow",
help="Manage and run automation workflows",
add_completion=False,
)
app.add_typer(workflow_app, name="workflow")
workflow_catalog_app = typer.Typer(
name="catalog",
help="Manage workflow catalogs",
add_completion=False,
)
workflow_app.add_typer(workflow_catalog_app, name="catalog")
@workflow_app.command("run")
def workflow_run(
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
input_values: list[str] | None = typer.Option(
None, "--input", "-i", help="Input values as key=value pairs"
),
):
"""Run a workflow from an installed ID or local YAML path."""
from .workflows.engine import WorkflowEngine
project_root = Path.cwd()
if not (project_root / ".specify").exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
raise typer.Exit(1)
engine = WorkflowEngine(project_root)
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
try:
definition = engine.load_workflow(source)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Workflow not found: {source}")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
raise typer.Exit(1)
# Validate
errors = engine.validate(definition)
if errors:
console.print("[red]Workflow validation failed:[/red]")
for err in errors:
console.print(f"{err}")
raise typer.Exit(1)
# Parse inputs
inputs: dict[str, Any] = {}
if input_values:
for kv in input_values:
if "=" not in kv:
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
raise typer.Exit(1)
key, _, value = kv.partition("=")
inputs[key.strip()] = value.strip()
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
console.print(f"[dim]Version: {definition.version}[/dim]\n")
try:
state = engine.execute(definition, inputs)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
console.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)
status_colors = {
"completed": "green",
"paused": "yellow",
"failed": "red",
"aborted": "red",
}
color = status_colors.get(state.status.value, "white")
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
console.print(f"[dim]Run ID: {state.run_id}[/dim]")
if state.status.value == "paused":
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
@workflow_app.command("resume")
def workflow_resume(
run_id: str = typer.Argument(..., help="Run ID to resume"),
):
"""Resume a paused or failed workflow run."""
from .workflows.engine import WorkflowEngine
project_root = Path.cwd()
if not (project_root / ".specify").exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
raise typer.Exit(1)
engine = WorkflowEngine(project_root)
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
try:
state = engine.resume(run_id)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
except Exception as exc:
console.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)
status_colors = {
"completed": "green",
"paused": "yellow",
"failed": "red",
"aborted": "red",
}
color = status_colors.get(state.status.value, "white")
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
@workflow_app.command("status")
def workflow_status(
run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
):
"""Show workflow run status."""
from .workflows.engine import WorkflowEngine
project_root = Path.cwd()
if not (project_root / ".specify").exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
raise typer.Exit(1)
engine = WorkflowEngine(project_root)
if run_id:
try:
from .workflows.engine import RunState
state = RunState.load(run_id, project_root)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
status_colors = {
"completed": "green",
"paused": "yellow",
"failed": "red",
"aborted": "red",
"running": "blue",
"created": "dim",
}
color = status_colors.get(state.status.value, "white")
console.print(f"\n[bold cyan]Workflow Run: {state.run_id}[/bold cyan]")
console.print(f" Workflow: {state.workflow_id}")
console.print(f" Status: [{color}]{state.status.value}[/{color}]")
console.print(f" Created: {state.created_at}")
console.print(f" Updated: {state.updated_at}")
if state.current_step_id:
console.print(f" Current: {state.current_step_id}")
if state.step_results:
console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]")
for step_id, step_data in state.step_results.items():
s = step_data.get("status", "unknown")
sc = {"completed": "green", "failed": "red", "paused": "yellow"}.get(s, "white")
console.print(f" [{sc}]●[/{sc}] {step_id}: {s}")
else:
runs = engine.list_runs()
if not runs:
console.print("[yellow]No workflow runs found.[/yellow]")
return
console.print("\n[bold cyan]Workflow Runs:[/bold cyan]\n")
for run_data in runs:
s = run_data.get("status", "unknown")
sc = {"completed": "green", "failed": "red", "paused": "yellow", "running": "blue"}.get(s, "white")
console.print(
f" [{sc}]●[/{sc}] {run_data['run_id']} "
f"{run_data.get('workflow_id', '?')} "
f"[{sc}]{s}[/{sc}] "
f"[dim]{run_data.get('updated_at', '?')}[/dim]"
)
@workflow_app.command("list")
def workflow_list():
"""List installed workflows."""
from .workflows.catalog import WorkflowRegistry
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
raise typer.Exit(1)
registry = WorkflowRegistry(project_root)
installed = registry.list()
if not installed:
console.print("[yellow]No workflows installed.[/yellow]")
console.print("\nInstall a workflow with:")
console.print(" [cyan]specify workflow add <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()

View File

@@ -18,6 +18,7 @@ import yaml
def _build_agent_configs() -> dict[str, Any]:
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
from specify_cli.integrations import INTEGRATION_REGISTRY
configs: dict[str, dict[str, Any]] = {}
for key, integration in INTEGRATION_REGISTRY.items():
if key == "generic":
@@ -75,7 +76,7 @@ class CommandRegistrar:
return {}, content
frontmatter_str = content[3:end_marker].strip()
body = content[end_marker + 3:].strip()
body = content[end_marker + 3 :].strip()
try:
frontmatter = yaml.safe_load(frontmatter_str) or {}
@@ -100,7 +101,9 @@ class CommandRegistrar:
if not fm:
return ""
yaml_str = yaml.dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True)
yaml_str = yaml.dump(
fm, default_flow_style=False, sort_keys=False, allow_unicode=True
)
return f"---\n{yaml_str}---\n"
def _adjust_script_paths(self, frontmatter: dict) -> dict:
@@ -146,16 +149,16 @@ class CommandRegistrar:
# ".specify/extensions/<ext>/scripts/..." remain intact.
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text)
text = re.sub(
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
)
return text.replace(".specify/.specify/", ".specify/").replace(".specify.specify/", ".specify/")
return text.replace(".specify/.specify/", ".specify/").replace(
".specify.specify/", ".specify/"
)
def render_markdown_command(
self,
frontmatter: dict,
body: str,
source_id: str,
context_note: str = None
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
) -> str:
"""Render command in Markdown format.
@@ -172,12 +175,7 @@ class CommandRegistrar:
context_note = f"\n<!-- Source: {source_id} -->\n"
return self.render_frontmatter(frontmatter) + "\n" + context_note + body
def render_toml_command(
self,
frontmatter: dict,
body: str,
source_id: str
) -> str:
def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> str:
"""Render command in TOML format.
Args:
@@ -192,7 +190,7 @@ class CommandRegistrar:
if "description" in frontmatter:
toml_lines.append(
f'description = {self._render_basic_toml_string(frontmatter["description"])}'
f"description = {self._render_basic_toml_string(frontmatter['description'])}"
)
toml_lines.append("")
@@ -226,6 +224,41 @@ class CommandRegistrar:
)
return f'"{escaped}"'
def render_yaml_command(
self,
frontmatter: dict,
body: str,
source_id: str,
cmd_name: str = "",
) -> str:
"""Render command in YAML recipe format for Goose.
Args:
frontmatter: Command frontmatter
body: Command body content
source_id: Source identifier (extension or preset ID)
cmd_name: Command name used as title fallback
Returns:
Formatted YAML recipe file content
"""
from specify_cli.integrations.base import YamlIntegration
title = frontmatter.get("title", "") or frontmatter.get("name", "")
if not isinstance(title, str):
title = str(title) if title is not None else ""
if not title and cmd_name:
title = YamlIntegration._human_title(cmd_name)
if not title and source_id:
title = YamlIntegration._human_title(Path(str(source_id)).stem)
if not title:
title = "Command"
description = frontmatter.get("description", "")
if not isinstance(description, str):
description = str(description) if description is not None else ""
return YamlIntegration._render_yaml(title, description, body, source_id)
def render_skill_command(
self,
agent_name: str,
@@ -252,9 +285,13 @@ class CommandRegistrar:
frontmatter = {}
if agent_name in {"codex", "kimi"}:
body = self.resolve_skill_placeholders(agent_name, frontmatter, body, project_root)
body = self.resolve_skill_placeholders(
agent_name, frontmatter, body, project_root
)
description = frontmatter.get("description", f"Spec-kit workflow command: {skill_name}")
description = frontmatter.get(
"description", f"Spec-kit workflow command: {skill_name}"
)
skill_frontmatter = self.build_skill_frontmatter(
agent_name,
skill_name,
@@ -288,7 +325,9 @@ class CommandRegistrar:
return skill_frontmatter
@staticmethod
def resolve_skill_placeholders(agent_name: str, frontmatter: dict, body: str, project_root: Path) -> str:
def resolve_skill_placeholders(
agent_name: str, frontmatter: dict, body: str, project_root: Path
) -> str:
"""Resolve script placeholders for skills-backed agents."""
try:
from . import load_init_options
@@ -312,7 +351,9 @@ class CommandRegistrar:
script_variant = init_opts.get("script")
if script_variant not in {"sh", "ps"}:
fallback_order = []
default_variant = "ps" if platform.system().lower().startswith("win") else "sh"
default_variant = (
"ps" if platform.system().lower().startswith("win") else "sh"
)
secondary_variant = "sh" if default_variant == "ps" else "ps"
if default_variant in scripts or default_variant in agent_scripts:
@@ -334,7 +375,9 @@ class CommandRegistrar:
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{SCRIPT}", script_command)
agent_script_command = agent_scripts.get(script_variant) if script_variant else None
agent_script_command = (
agent_scripts.get(script_variant) if script_variant else None
)
if agent_script_command:
agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{AGENT_SCRIPT}", agent_script_command)
@@ -342,7 +385,9 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
return CommandRegistrar.rewrite_project_relative_paths(body)
def _convert_argument_placeholder(self, content: str, from_placeholder: str, to_placeholder: str) -> str:
def _convert_argument_placeholder(
self, content: str, from_placeholder: str, to_placeholder: str
) -> str:
"""Convert argument placeholder format.
Args:
@@ -356,14 +401,16 @@ class CommandRegistrar:
return content.replace(from_placeholder, to_placeholder)
@staticmethod
def _compute_output_name(agent_name: str, cmd_name: str, agent_config: Dict[str, Any]) -> str:
def _compute_output_name(
agent_name: str, cmd_name: str, agent_config: Dict[str, Any]
) -> str:
"""Compute the on-disk command or skill name for an agent."""
if agent_config["extension"] != "/SKILL.md":
return cmd_name
short_name = cmd_name
if short_name.startswith("speckit."):
short_name = short_name[len("speckit."):]
short_name = short_name[len("speckit.") :]
short_name = short_name.replace(".", "-")
return f"speckit-{short_name}"
@@ -375,7 +422,7 @@ class CommandRegistrar:
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None
context_note: str = None,
) -> List[str]:
"""Register commands for a specific agent.
@@ -432,12 +479,24 @@ class CommandRegistrar:
if agent_config["extension"] == "/SKILL.md":
output = self.render_skill_command(
agent_name, output_name, frontmatter, body, source_id, cmd_file, project_root
agent_name,
output_name,
frontmatter,
body,
source_id,
cmd_file,
project_root,
)
elif agent_config["format"] == "markdown":
output = self.render_markdown_command(frontmatter, body, source_id, context_note)
output = self.render_markdown_command(
frontmatter, body, source_id, context_note
)
elif agent_config["format"] == "toml":
output = self.render_toml_command(frontmatter, body, source_id)
elif agent_config["format"] == "yaml":
output = self.render_yaml_command(
frontmatter, body, source_id, cmd_name
)
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
@@ -451,34 +510,68 @@ class CommandRegistrar:
registered.append(cmd_name)
for alias in cmd_info.get("aliases", []):
alias_output_name = self._compute_output_name(agent_name, alias, agent_config)
alias_output_name = self._compute_output_name(
agent_name, alias, agent_config
)
# For agents with inject_name, render with alias-specific frontmatter
if agent_config.get("inject_name"):
alias_frontmatter = deepcopy(frontmatter)
# Use custom name formatter if provided (e.g., Forge's hyphenated format)
format_name = agent_config.get("format_name")
alias_frontmatter["name"] = format_name(alias) if format_name else alias
alias_frontmatter["name"] = (
format_name(alias) if format_name else alias
)
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name, alias_output_name, alias_frontmatter, body, source_id, cmd_file, project_root
agent_name,
alias_output_name,
alias_frontmatter,
body,
source_id,
cmd_file,
project_root,
)
elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command(alias_frontmatter, body, source_id, context_note)
alias_output = self.render_markdown_command(
alias_frontmatter, body, source_id, context_note
)
elif agent_config["format"] == "toml":
alias_output = self.render_toml_command(alias_frontmatter, body, source_id)
alias_output = self.render_toml_command(
alias_frontmatter, body, source_id
)
elif agent_config["format"] == "yaml":
alias_output = self.render_yaml_command(
alias_frontmatter, body, source_id, alias
)
else:
raise ValueError(f"Unsupported format: {agent_config['format']}")
raise ValueError(
f"Unsupported format: {agent_config['format']}"
)
else:
# For other agents, reuse the primary output
alias_output = output
if agent_config["extension"] == "/SKILL.md":
alias_output = self.render_skill_command(
agent_name, alias_output_name, frontmatter, body, source_id, cmd_file, project_root
agent_name,
alias_output_name,
frontmatter,
body,
source_id,
cmd_file,
project_root,
)
alias_file = commands_dir / f"{alias_output_name}{agent_config['extension']}"
alias_file = (
commands_dir / f"{alias_output_name}{agent_config['extension']}"
)
try:
alias_file.resolve().relative_to(commands_dir.resolve())
except ValueError:
raise ValueError(
f"Alias output path escapes commands directory: {alias_file!r}"
)
alias_file.parent.mkdir(parents=True, exist_ok=True)
alias_file.write_text(alias_output, encoding="utf-8")
if agent_name == "copilot":
@@ -506,7 +599,7 @@ class CommandRegistrar:
source_id: str,
source_dir: Path,
project_root: Path,
context_note: str = None
context_note: str = None,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
@@ -529,8 +622,12 @@ class CommandRegistrar:
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name, commands, source_id, source_dir, project_root,
context_note=context_note
agent_name,
commands,
source_id,
source_dir,
project_root,
context_note=context_note,
)
if registered:
results[agent_name] = registered
@@ -540,9 +637,7 @@ class CommandRegistrar:
return results
def unregister_commands(
self,
registered_commands: Dict[str, List[str]],
project_root: Path
self, registered_commands: Dict[str, List[str]], project_root: Path
) -> None:
"""Remove previously registered command files from agent directories.
@@ -559,13 +654,17 @@ class CommandRegistrar:
commands_dir = project_root / agent_config["dir"]
for cmd_name in cmd_names:
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
output_name = self._compute_output_name(
agent_name, cmd_name, agent_config
)
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
if cmd_file.exists():
cmd_file.unlink()
if agent_name == "copilot":
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
prompt_file = (
project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
)
if prompt_file.exists():
prompt_file.unlink()

View File

@@ -38,6 +38,8 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
})
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
def _load_core_command_names() -> frozenset[str]:
"""Discover bundled core command names from the packaged templates.
@@ -1870,6 +1872,14 @@ class ExtensionCatalog:
if not ext_info:
raise ExtensionError(f"Extension '{extension_id}' not found in catalog")
# Bundled extensions without a download URL must be installed locally
if ext_info.get("bundled") and not ext_info.get("download_url"):
raise ExtensionError(
f"Extension '{extension_id}' is bundled with spec-kit and has no download URL. "
f"It should be installed from the local package. "
f"Try reinstalling: {REINSTALL_COMMAND}"
)
download_url = ext_info.get("download_url")
if not download_url:
raise ExtensionError(f"Extension '{extension_id}' has no download URL")
@@ -2170,6 +2180,7 @@ class HookExecutor:
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
kimi_skill_mode = selected_ai == "kimi"
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
skill_name = self._skill_name_from_command(command_id)
if codex_skill_mode and skill_name:
@@ -2178,6 +2189,8 @@ class HookExecutor:
return f"/{skill_name}"
if kimi_skill_mode and skill_name:
return f"/skill:{skill_name}"
if cursor_skill_mode and skill_name:
return f"/{skill_name}"
return f"/{command_id}"

View File

@@ -36,6 +36,7 @@ def get_integration(key: str) -> IntegrationBase | None:
# -- Register built-in integrations --------------------------------------
def _register_builtins() -> None:
"""Register all built-in integrations.
@@ -58,6 +59,7 @@ def _register_builtins() -> None:
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
from .generic import GenericIntegration
from .goose import GooseIntegration
from .iflow import IflowIntegration
from .junie import JunieIntegration
from .kilocode import KilocodeIntegration
@@ -87,6 +89,7 @@ def _register_builtins() -> None:
_register(ForgeIntegration())
_register(GeminiIntegration())
_register(GenericIntegration())
_register(GooseIntegration())
_register(IflowIntegration())
_register(JunieIntegration())
_register(KilocodeIntegration())

View File

@@ -28,6 +28,7 @@ if TYPE_CHECKING:
# IntegrationOption
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class IntegrationOption:
"""Declares an option that an integration accepts via ``--integration-options``.
@@ -51,6 +52,7 @@ class IntegrationOption:
# IntegrationBase — abstract base class
# ---------------------------------------------------------------------------
class IntegrationBase(ABC):
"""Abstract base class every integration must implement.
@@ -89,6 +91,123 @@ 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:
@@ -275,7 +394,7 @@ class IntegrationBase(ABC):
2. Replace ``{SCRIPT}`` with the extracted script command
3. Extract ``agent_scripts.<script_type>`` and replace ``{AGENT_SCRIPT}``
4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter
5. Replace ``{ARGS}`` with *arg_placeholder*
5. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
6. Replace ``__AGENT__`` with *agent_name*
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
"""
@@ -348,8 +467,9 @@ class IntegrationBase(ABC):
output_lines.append(line)
content = "".join(output_lines)
# 5. Replace {ARGS}
# 5. Replace {ARGS} and $ARGUMENTS
content = content.replace("{ARGS}", arg_placeholder)
content = content.replace("$ARGUMENTS", arg_placeholder)
# 6. Replace __AGENT__
content = content.replace("__AGENT__", agent_name)
@@ -358,6 +478,7 @@ class IntegrationBase(ABC):
# CommandRegistrar so extension-local paths are preserved and
# boundary rules stay consistent across the codebase.
from specify_cli.agents import CommandRegistrar
content = CommandRegistrar.rewrite_project_relative_paths(content)
return content
@@ -433,9 +554,7 @@ class IntegrationBase(ABC):
**opts: Any,
) -> list[Path]:
"""High-level install — calls ``setup()`` and returns created files."""
return self.setup(
project_root, manifest, parsed_options=parsed_options, **opts
)
return self.setup(project_root, manifest, parsed_options=parsed_options, **opts)
def uninstall(
self,
@@ -452,6 +571,7 @@ class IntegrationBase(ABC):
# MarkdownIntegration — covers ~20 standard agents
# ---------------------------------------------------------------------------
class MarkdownIntegration(IntegrationBase):
"""Concrete base for integrations that use standard Markdown commands.
@@ -463,6 +583,22 @@ class MarkdownIntegration(IntegrationBase):
integration-specific scripts (``update-context.sh`` / ``.ps1``).
"""
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args
def setup(
self,
project_root: Path,
@@ -492,12 +628,18 @@ class MarkdownIntegration(IntegrationBase):
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") if self.registrar_config else "$ARGUMENTS"
arg_placeholder = (
self.registrar_config.get("args", "$ARGUMENTS")
if self.registrar_config
else "$ARGUMENTS"
)
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
processed, dest / dst_name, project_root, manifest
@@ -512,6 +654,7 @@ class MarkdownIntegration(IntegrationBase):
# TomlIntegration — TOML-format agents (Gemini, Tabnine)
# ---------------------------------------------------------------------------
class TomlIntegration(IntegrationBase):
"""Concrete base for integrations that use TOML command format.
@@ -524,6 +667,22 @@ class TomlIntegration(IntegrationBase):
TOML format (``description`` key + ``prompt`` multiline string).
"""
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
if not self.config or not self.config.get("requires_cli"):
return None
args = [self.key, "-p", prompt]
if model:
args.extend(["-m", model])
if output_json:
args.extend(["--output-format", "json"])
return args
def command_filename(self, template_name: str) -> str:
"""TOML commands use ``.toml`` extension."""
return f"speckit.{template_name}.toml"
@@ -603,13 +762,17 @@ class TomlIntegration(IntegrationBase):
if "'''" not in value and not value.endswith("'"):
return "'''\n" + value + "'''"
return '"' + (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
) + '"'
return (
'"'
+ (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
+ '"'
)
@staticmethod
def _render_toml(description: str, body: str) -> str:
@@ -628,7 +791,9 @@ class TomlIntegration(IntegrationBase):
toml_lines: list[str] = []
if description:
toml_lines.append(f"description = {TomlIntegration._render_toml_string(description)}")
toml_lines.append(
f"description = {TomlIntegration._render_toml_string(description)}"
)
toml_lines.append("")
body = body.rstrip("\n")
@@ -665,13 +830,19 @@ class TomlIntegration(IntegrationBase):
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "{{args}}") if self.registrar_config else "{{args}}"
arg_placeholder = (
self.registrar_config.get("args", "{{args}}")
if self.registrar_config
else "{{args}}"
)
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
description = self._extract_description(raw)
processed = self.process_template(raw, self.key, script_type, arg_placeholder)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder
)
_, body = self._split_frontmatter(processed)
toml_content = self._render_toml(description, body)
dst_name = self.command_filename(src_file.stem)
@@ -684,6 +855,188 @@ class TomlIntegration(IntegrationBase):
return created
# ---------------------------------------------------------------------------
# YamlIntegration — YAML-format agents (Goose)
# ---------------------------------------------------------------------------
class YamlIntegration(IntegrationBase):
"""Concrete base for integrations that use YAML recipe format.
Mirrors ``TomlIntegration`` closely: subclasses only need to set
``key``, ``config``, ``registrar_config`` (and optionally
``context_file``). Everything else is inherited.
``setup()`` processes command templates through the same placeholder
pipeline as ``MarkdownIntegration``, then converts the result to
YAML recipe format (version, title, description, prompt block scalar).
"""
def command_filename(self, template_name: str) -> str:
"""YAML commands use ``.yaml`` extension."""
return f"speckit.{template_name}.yaml"
@staticmethod
def _extract_frontmatter(content: str) -> dict[str, Any]:
"""Extract frontmatter as a dict from YAML frontmatter block."""
import yaml
if not content.startswith("---"):
return {}
lines = content.splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != "---":
return {}
frontmatter_end = -1
for i, line in enumerate(lines[1:], start=1):
if line.rstrip("\r\n") == "---":
frontmatter_end = i
break
if frontmatter_end == -1:
return {}
frontmatter_text = "".join(lines[1:frontmatter_end])
try:
fm = yaml.safe_load(frontmatter_text) or {}
except yaml.YAMLError:
return {}
return fm if isinstance(fm, dict) else {}
@staticmethod
def _split_frontmatter(content: str) -> tuple[str, str]:
"""Split YAML frontmatter from the remaining body content."""
if not content.startswith("---"):
return "", content
lines = content.splitlines(keepends=True)
if not lines or lines[0].rstrip("\r\n") != "---":
return "", content
frontmatter_end = -1
for i, line in enumerate(lines[1:], start=1):
if line.rstrip("\r\n") == "---":
frontmatter_end = i
break
if frontmatter_end == -1:
return "", content
frontmatter = "".join(lines[1:frontmatter_end])
body = "".join(lines[frontmatter_end + 1 :])
return frontmatter, body
@staticmethod
def _human_title(identifier: str) -> str:
"""Convert an identifier to a human-readable title.
Strips a leading ``speckit.`` prefix and replaces ``.``, ``-``,
and ``_`` with spaces before title-casing.
"""
text = identifier
if text.startswith("speckit."):
text = text[len("speckit.") :]
return text.replace(".", " ").replace("-", " ").replace("_", " ").title()
@staticmethod
def _render_yaml(title: str, description: str, body: str, source_id: str) -> str:
"""Render a YAML recipe file from title, description, and body.
Produces a Goose-compatible recipe with a literal block scalar
for the prompt content. Uses ``yaml.safe_dump()`` for the
header fields to ensure proper escaping.
"""
import yaml
header = {
"version": "1.0.0",
"title": title,
"description": description,
"author": {"contact": "spec-kit"},
"extensions": [{"type": "builtin", "name": "developer"}],
"activities": ["Spec-Driven Development"],
}
header_yaml = yaml.safe_dump(
header,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
).strip()
# Indent each line for YAML block scalar
indented = "\n".join(f" {line}" for line in body.split("\n"))
lines = [header_yaml, "prompt: |", indented, "", f"# Source: {source_id}"]
return "\n".join(lines) + "\n"
def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
templates = self.list_command_templates()
if not templates:
return []
project_root_resolved = project_root.resolve()
if manifest.project_root != project_root_resolved:
raise ValueError(
f"manifest.project_root ({manifest.project_root}) does not match "
f"project_root ({project_root_resolved})"
)
dest = self.commands_dest(project_root).resolve()
try:
dest.relative_to(project_root_resolved)
except ValueError as exc:
raise ValueError(
f"Integration destination {dest} escapes "
f"project root {project_root_resolved}"
) from exc
dest.mkdir(parents=True, exist_ok=True)
script_type = opts.get("script_type", "sh")
arg_placeholder = (
self.registrar_config.get("args", "{{args}}")
if self.registrar_config
else "{{args}}"
)
created: list[Path] = []
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
fm = self._extract_frontmatter(raw)
description = fm.get("description", "")
if not isinstance(description, str):
description = str(description) if description is not None else ""
title = fm.get("title", "") or fm.get("name", "")
if not isinstance(title, str):
title = str(title) if title is not None else ""
if not title:
title = self._human_title(src_file.stem)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder
)
_, body = self._split_frontmatter(processed)
yaml_content = self._render_yaml(
title, description, body, f"templates/commands/{src_file.name}"
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
yaml_content, dest / dst_name, project_root, manifest
)
created.append(dst_file)
created.extend(self.install_scripts(project_root, manifest))
return created
# ---------------------------------------------------------------------------
# SkillsIntegration — skills-format agents (Codex, Kimi, Agy)
# ---------------------------------------------------------------------------
@@ -704,6 +1057,22 @@ 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.
@@ -713,9 +1082,7 @@ class SkillsIntegration(IntegrationBase):
Raises ``ValueError`` when ``config`` or ``folder`` is missing.
"""
if not self.config:
raise ValueError(
f"{type(self).__name__}.config is not set."
)
raise ValueError(f"{type(self).__name__}.config is not set.")
folder = self.config.get("folder")
if not folder:
raise ValueError(
@@ -724,6 +1091,17 @@ 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 setup(
self,
project_root: Path,

View File

@@ -28,6 +28,21 @@ class CodexIntegration(SkillsIntegration):
}
context_file = "AGENTS.md"
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
# Codex uses ``codex exec "prompt"`` for non-interactive mode.
args: list[str] = ["codex", "exec", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.append("--json")
return args
@classmethod
def options(cls) -> list[IntegrationOption]:
return [

View File

@@ -19,14 +19,19 @@ from ..manifest import IntegrationManifest
class CopilotIntegration(IntegrationBase):
"""Integration for GitHub Copilot in VS Code."""
"""Integration for GitHub Copilot (VS Code IDE + CLI).
The IDE integration (``requires_cli: False``) installs ``.agent.md``
command files. Workflow dispatch additionally requires the
``copilot`` CLI to be installed separately.
"""
key = "copilot"
config = {
"name": "GitHub Copilot",
"folder": ".github/",
"commands_subdir": "agents",
"install_url": None,
"install_url": "https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli",
"requires_cli": False,
}
registrar_config = {
@@ -37,6 +42,101 @@ class CopilotIntegration(IntegrationBase):
}
context_file = ".github/copilot-instructions.md"
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
# GitHub Copilot CLI uses ``copilot -p "prompt"`` for
# non-interactive mode. --allow-all-tools is required for the
# agent to perform file edits and shell commands. Controlled
# by SPECKIT_ALLOW_ALL_TOOLS env var (default: enabled).
import os
args = ["copilot", "-p", prompt]
if os.environ.get("SPECKIT_ALLOW_ALL_TOOLS", "1") != "0":
args.append("--allow-all-tools")
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Copilot agents are not slash-commands — just return the args as prompt."""
return args or ""
def dispatch_command(
self,
command_name: str,
args: str = "",
*,
project_root: Path | None = None,
model: str | None = None,
timeout: int = 600,
stream: bool = True,
) -> dict[str, Any]:
"""Dispatch via ``--agent speckit.<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"

View File

@@ -1,21 +1,39 @@
"""Cursor IDE integration."""
"""Cursor IDE integration.
from ..base import MarkdownIntegration
Cursor Agent uses the ``.cursor/skills/speckit-<name>/SKILL.md`` layout.
Commands are deprecated; ``--skills`` defaults to ``True``.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class CursorAgentIntegration(MarkdownIntegration):
class CursorAgentIntegration(SkillsIntegration):
key = "cursor-agent"
config = {
"name": "Cursor",
"folder": ".cursor/",
"commands_subdir": "commands",
"commands_subdir": "skills",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".cursor/commands",
"dir": ".cursor/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
"extension": "/SKILL.md",
}
context_file = ".cursor/rules/specify-rules.mdc"
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (recommended for Cursor)",
),
]

View File

@@ -0,0 +1,21 @@
"""Goose integration — Block's open source AI agent."""
from ..base import YamlIntegration
class GooseIntegration(YamlIntegration):
key = "goose"
config = {
"name": "Goose",
"folder": ".goose/",
"commands_subdir": "recipes",
"install_url": "https://block.github.io/goose/docs/getting-started/installation",
"requires_cli": True,
}
registrar_config = {
"dir": ".goose/recipes",
"format": "yaml",
"args": "{{args}}",
"extension": ".yaml",
}
context_file = "AGENTS.md"

View File

@@ -0,0 +1,33 @@
# update-context.ps1 — Goose integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
$ErrorActionPreference = 'Stop'
# Derive repo root from script location (walks up to find .specify/)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
# If git did not return a repo root, or the git root does not contain .specify,
# fall back to walking up from the script directory to find the initialized project root.
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1"
# Always delegate to the shared updater; fail clearly if it is unavailable.
if (-not (Test-Path $sharedScript)) {
Write-Error "Error: shared agent context updater not found: $sharedScript"
Write-Error "Goose integration requires support in scripts/powershell/update-agent-context.ps1."
exit 1
}
& $sharedScript -AgentType goose
exit $LASTEXITCODE

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# update-context.sh — Goose integration: create/update AGENTS.md
#
# Thin wrapper that delegates to the shared update-agent-context script.
# Activated in Stage 7 when the shared script uses integration.json dispatch.
#
# Until then, this delegates to the shared script as a subprocess.
set -euo pipefail
# Derive repo root from script location (walks up to find .specify/)
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh"
# Always delegate to the shared updater; fail clearly if it is unavailable.
if [ ! -x "$shared_script" ]; then
echo "Error: shared agent context updater not found or not executable:" >&2
echo " $shared_script" >&2
echo "Goose integration requires support in scripts/bash/update-agent-context.sh." >&2
exit 1
fi
exec "$shared_script" goose

View File

@@ -1587,6 +1587,16 @@ class PresetCatalog:
f"Preset '{pack_id}' not found in catalog"
)
# Bundled presets without a download URL must be installed locally
if pack_info.get("bundled") and not pack_info.get("download_url"):
from .extensions import REINSTALL_COMMAND
raise PresetError(
f"Preset '{pack_id}' is bundled with spec-kit and has no download URL. "
f"It should be installed from the local package. "
f"Use 'specify preset add {pack_id}' to install from the bundled package, "
f"or reinstall spec-kit if the bundled files are missing: {REINSTALL_COMMAND}"
)
if not pack_info.get("_install_allowed", True):
catalog_name = pack_info.get("_catalog_name", "unknown")
raise PresetError(

View File

@@ -0,0 +1,68 @@
"""Workflow engine for multi-step, resumable automation workflows.
Provides:
- ``StepBase`` — abstract base every step type must implement.
- ``StepContext`` — execution context passed to each step.
- ``StepResult`` — return value from step execution.
- ``STEP_REGISTRY`` — maps ``type_key`` to ``StepBase`` subclass instances.
- ``WorkflowEngine`` — orchestrator that loads, validates, and executes
workflow YAML definitions.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .base import StepBase
# Maps step type_key → StepBase instance.
STEP_REGISTRY: dict[str, StepBase] = {}
def _register_step(step: StepBase) -> None:
"""Register a step type instance in the global registry.
Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
"""
key = step.type_key
if not key:
raise ValueError("Cannot register step type with an empty type_key.")
if key in STEP_REGISTRY:
raise KeyError(f"Step type with key {key!r} is already registered.")
STEP_REGISTRY[key] = step
def get_step_type(type_key: str) -> StepBase | None:
"""Return the step type for *type_key*, or ``None`` if not registered."""
return STEP_REGISTRY.get(type_key)
# -- Register built-in step types ----------------------------------------
def _register_builtin_steps() -> None:
"""Register all built-in step types."""
from .steps.command import CommandStep
from .steps.do_while import DoWhileStep
from .steps.fan_in import FanInStep
from .steps.fan_out import FanOutStep
from .steps.gate import GateStep
from .steps.if_then import IfThenStep
from .steps.prompt import PromptStep
from .steps.shell import ShellStep
from .steps.switch import SwitchStep
from .steps.while_loop import WhileStep
_register_step(CommandStep())
_register_step(DoWhileStep())
_register_step(FanInStep())
_register_step(FanOutStep())
_register_step(GateStep())
_register_step(IfThenStep())
_register_step(PromptStep())
_register_step(ShellStep())
_register_step(SwitchStep())
_register_step(WhileStep())
_register_builtin_steps()

View File

@@ -0,0 +1,132 @@
"""Base classes for workflow step types.
Provides:
- ``StepBase`` — abstract base every step type must implement.
- ``StepContext`` — execution context passed to each step.
- ``StepResult`` — return value from step execution.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class StepStatus(str, Enum):
"""Status of a step execution."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
PAUSED = "paused"
class RunStatus(str, Enum):
"""Status of a workflow run."""
CREATED = "created"
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
ABORTED = "aborted"
@dataclass
class StepContext:
"""Execution context passed to each step.
Contains everything the step needs to resolve expressions, dispatch
commands, and record results.
"""
#: Resolved workflow inputs (from user prompts / defaults).
inputs: dict[str, Any] = field(default_factory=dict)
#: Accumulated step results keyed by step ID.
#: Each entry is ``{"integration": ..., "model": ..., "options": ...,
#: "input": ..., "output": ...}``.
steps: dict[str, dict[str, Any]] = field(default_factory=dict)
#: Current fan-out item (set only inside fan-out iterations).
item: Any = None
#: Fan-in aggregated results (set only for fan-in steps).
fan_in: dict[str, Any] = field(default_factory=dict)
#: Workflow-level default integration key.
default_integration: str | None = None
#: Workflow-level default model.
default_model: str | None = None
#: Workflow-level default options.
default_options: dict[str, Any] = field(default_factory=dict)
#: Project root path.
project_root: str | None = None
#: Current run ID.
run_id: str | None = None
@dataclass
class StepResult:
"""Return value from a step execution."""
#: Step status.
status: StepStatus = StepStatus.COMPLETED
#: Output data (stored as ``steps.<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

View File

@@ -0,0 +1,540 @@
"""Workflow catalog — discovery, install, and management of workflows.
Mirrors the existing extension/preset catalog pattern with:
- Multi-catalog stack (env var → project → user → built-in)
- SHA256-hashed per-URL caching with 1-hour TTL
- Workflow registry for installed workflow tracking
- Search across all configured catalog sources
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class WorkflowCatalogError(Exception):
"""Base error for workflow catalog operations."""
class WorkflowValidationError(WorkflowCatalogError):
"""Validation error for catalog config or workflow data."""
# ---------------------------------------------------------------------------
# CatalogEntry
# ---------------------------------------------------------------------------
@dataclass
class WorkflowCatalogEntry:
"""Represents a single catalog source in the catalog stack."""
url: str
name: str
priority: int
install_allowed: bool
description: str = ""
# ---------------------------------------------------------------------------
# WorkflowRegistry
# ---------------------------------------------------------------------------
class WorkflowRegistry:
"""Manages the registry of installed workflows.
Tracks installed workflows and their metadata in
``.specify/workflows/workflow-registry.json``.
"""
REGISTRY_FILE = "workflow-registry.json"
SCHEMA_VERSION = "1.0"
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.workflows_dir = project_root / ".specify" / "workflows"
self.registry_path = self.workflows_dir / self.REGISTRY_FILE
self.data = self._load()
def _load(self) -> dict[str, Any]:
"""Load registry from disk or create default."""
if self.registry_path.exists():
try:
with open(self.registry_path, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, ValueError):
# Corrupted registry file — reset to default
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
return {"schema_version": self.SCHEMA_VERSION, "workflows": {}}
def save(self) -> None:
"""Persist registry to disk."""
self.workflows_dir.mkdir(parents=True, exist_ok=True)
with open(self.registry_path, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
def add(self, workflow_id: str, metadata: dict[str, Any]) -> None:
"""Add or update an installed workflow entry."""
from datetime import datetime, timezone
existing = self.data["workflows"].get(workflow_id, {})
metadata["installed_at"] = existing.get(
"installed_at", datetime.now(timezone.utc).isoformat()
)
metadata["updated_at"] = datetime.now(timezone.utc).isoformat()
self.data["workflows"][workflow_id] = metadata
self.save()
def remove(self, workflow_id: str) -> bool:
"""Remove an installed workflow entry. Returns True if found."""
if workflow_id in self.data["workflows"]:
del self.data["workflows"][workflow_id]
self.save()
return True
return False
def get(self, workflow_id: str) -> dict[str, Any] | None:
"""Get metadata for an installed workflow."""
return self.data["workflows"].get(workflow_id)
def list(self) -> dict[str, dict[str, Any]]:
"""Return all installed workflows."""
return dict(self.data["workflows"])
def is_installed(self, workflow_id: str) -> bool:
"""Check if a workflow is installed."""
return workflow_id in self.data["workflows"]
# ---------------------------------------------------------------------------
# WorkflowCatalog
# ---------------------------------------------------------------------------
class WorkflowCatalog:
"""Manages workflow catalog fetching, caching, and searching.
Resolution order for catalog sources:
1. ``SPECKIT_WORKFLOW_CATALOG_URL`` env var (overrides all)
2. Project-level ``.specify/workflow-catalogs.yml``
3. User-level ``~/.specify/workflow-catalogs.yml``
4. Built-in defaults (official + community)
"""
DEFAULT_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/"
"workflows/catalog.json"
)
COMMUNITY_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/"
"workflows/catalog.community.json"
)
CACHE_DURATION = 3600 # 1 hour
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.workflows_dir = project_root / ".specify" / "workflows"
self.cache_dir = self.workflows_dir / ".cache"
# -- Catalog resolution -----------------------------------------------
def _validate_catalog_url(self, url: str) -> None:
"""Validate that a catalog URL uses HTTPS (localhost HTTP allowed)."""
from urllib.parse import urlparse
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
raise WorkflowValidationError(
f"Catalog URL must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not parsed.netloc:
raise WorkflowValidationError(
"Catalog URL must be a valid URL with a host."
)
def _load_catalog_config(
self, config_path: Path
) -> list[WorkflowCatalogEntry] | None:
"""Load catalog stack configuration from a YAML file."""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise WorkflowValidationError(
f"Failed to read catalog config {config_path}: {exc}"
)
catalogs_data = data.get("catalogs", [])
if not catalogs_data:
# Empty catalogs list (e.g. after removing last entry)
# is valid — fall back to built-in defaults.
return None
if not isinstance(catalogs_data, list):
raise WorkflowValidationError(
f"Invalid catalog config: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
entries: list[WorkflowCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise WorkflowValidationError(
f"Invalid catalog entry at index {idx}: "
f"expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip()
if not url:
continue
self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raise WorkflowValidationError(
f"Invalid priority for catalog "
f"'{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in (
"true",
"yes",
"1",
)
else:
install_allowed = bool(raw_install)
entries.append(
WorkflowCatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
priority=priority,
install_allowed=install_allowed,
description=str(item.get("description", "")),
)
)
entries.sort(key=lambda e: e.priority)
if not entries:
raise WorkflowValidationError(
f"Catalog config {config_path} contains {len(catalogs_data)} "
f"entries but none have valid URLs."
)
return entries
def get_active_catalogs(self) -> list[WorkflowCatalogEntry]:
"""Get the ordered list of active catalogs."""
# 1. Environment variable override
env_url = os.environ.get("SPECKIT_WORKFLOW_CATALOG_URL", "").strip()
if env_url:
self._validate_catalog_url(env_url)
return [
WorkflowCatalogEntry(
url=env_url,
name="env-override",
priority=1,
install_allowed=True,
description="From SPECKIT_WORKFLOW_CATALOG_URL",
)
]
# 2. Project-level config
project_config = self.project_root / ".specify" / "workflow-catalogs.yml"
project_entries = self._load_catalog_config(project_config)
if project_entries is not None:
return project_entries
# 3. User-level config
home = Path.home()
user_config = home / ".specify" / "workflow-catalogs.yml"
user_entries = self._load_catalog_config(user_config)
if user_entries is not None:
return user_entries
# 4. Built-in defaults
return [
WorkflowCatalogEntry(
url=self.DEFAULT_CATALOG_URL,
name="default",
priority=1,
install_allowed=True,
description="Official workflows",
),
WorkflowCatalogEntry(
url=self.COMMUNITY_CATALOG_URL,
name="community",
priority=2,
install_allowed=False,
description="Community-contributed workflows (discovery only)",
),
]
# -- Caching ----------------------------------------------------------
def _get_cache_paths(self, url: str) -> tuple[Path, Path]:
"""Get cache file paths for a URL (hash-based)."""
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16]
cache_file = self.cache_dir / f"workflow-catalog-{url_hash}.json"
meta_file = self.cache_dir / f"workflow-catalog-{url_hash}-meta.json"
return cache_file, meta_file
def _is_url_cache_valid(self, url: str) -> bool:
"""Check if cached data for a URL is still fresh."""
_, meta_file = self._get_cache_paths(url)
if not meta_file.exists():
return False
try:
with open(meta_file, encoding="utf-8") as f:
meta = json.load(f)
fetched_at = meta.get("fetched_at", 0)
return (time.time() - fetched_at) < self.CACHE_DURATION
except (json.JSONDecodeError, OSError):
return False
def _fetch_single_catalog(
self, entry: WorkflowCatalogEntry, force_refresh: bool = False
) -> dict[str, Any]:
"""Fetch a single catalog, using cache when possible."""
cache_file, meta_file = self._get_cache_paths(entry.url)
if not force_refresh and self._is_url_cache_valid(entry.url):
try:
with open(cache_file, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
# Fetch from URL — validate scheme before opening and after redirects
from urllib.parse import urlparse
from urllib.request import urlopen
def _validate_catalog_url(url: str) -> None:
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
raise WorkflowCatalogError(
f"Refusing to fetch catalog from non-HTTPS URL: {url}"
)
_validate_catalog_url(entry.url)
try:
with urlopen(entry.url, timeout=30) as resp: # noqa: S310
_validate_catalog_url(resp.geturl())
data = json.loads(resp.read().decode("utf-8"))
except Exception as exc:
# Fall back to cache if available
if cache_file.exists():
try:
with open(cache_file, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, ValueError, OSError):
pass
raise WorkflowCatalogError(
f"Failed to fetch catalog from {entry.url}: {exc}"
) from exc
if not isinstance(data, dict):
raise WorkflowCatalogError(
f"Catalog from {entry.url} is not a valid JSON object."
)
# Write cache
self.cache_dir.mkdir(parents=True, exist_ok=True)
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
with open(meta_file, "w", encoding="utf-8") as f:
json.dump({"url": entry.url, "fetched_at": time.time()}, f)
return data
def _get_merged_workflows(
self, force_refresh: bool = False
) -> dict[str, dict[str, Any]]:
"""Merge workflows from all active catalogs (lower priority number wins)."""
catalogs = self.get_active_catalogs()
merged: dict[str, dict[str, Any]] = {}
fetch_errors = 0
# Process later/higher-numbered entries first so earlier/lower-numbered
# entries overwrite them on workflow ID conflicts.
for entry in reversed(catalogs):
try:
data = self._fetch_single_catalog(entry, force_refresh)
except WorkflowCatalogError:
fetch_errors += 1
continue
workflows = data.get("workflows", {})
# Handle both dict and list formats
if isinstance(workflows, dict):
for wf_id, wf_data in workflows.items():
if not isinstance(wf_data, dict):
continue
wf_data["_catalog_name"] = entry.name
wf_data["_install_allowed"] = entry.install_allowed
merged[wf_id] = wf_data
elif isinstance(workflows, list):
for wf_data in workflows:
if not isinstance(wf_data, dict):
continue
wf_id = wf_data.get("id", "")
if wf_id:
wf_data["_catalog_name"] = entry.name
wf_data["_install_allowed"] = entry.install_allowed
merged[wf_id] = wf_data
if fetch_errors == len(catalogs) and catalogs:
raise WorkflowCatalogError(
"All configured catalogs failed to fetch."
)
return merged
# -- Public API -------------------------------------------------------
def search(
self,
query: str | None = None,
tag: str | None = None,
) -> list[dict[str, Any]]:
"""Search workflows across all configured catalogs."""
merged = self._get_merged_workflows()
results: list[dict[str, Any]] = []
for wf_id, wf_data in merged.items():
wf_data.setdefault("id", wf_id)
if query:
q = query.lower()
searchable = " ".join(
[
wf_data.get("name", ""),
wf_data.get("description", ""),
wf_data.get("id", ""),
]
).lower()
if q not in searchable:
continue
if tag:
raw_tags = wf_data.get("tags", [])
tags = raw_tags if isinstance(raw_tags, list) else []
normalized_tags = [t.lower() for t in tags if isinstance(t, str)]
if tag.lower() not in normalized_tags:
continue
results.append(wf_data)
return results
def get_workflow_info(self, workflow_id: str) -> dict[str, Any] | None:
"""Get details for a specific workflow from the catalog."""
merged = self._get_merged_workflows()
wf = merged.get(workflow_id)
if wf:
wf.setdefault("id", workflow_id)
return wf
def get_catalog_configs(self) -> list[dict[str, Any]]:
"""Return current catalog configuration as a list of dicts."""
entries = self.get_active_catalogs()
return [
{
"name": e.name,
"url": e.url,
"priority": e.priority,
"install_allowed": e.install_allowed,
"description": e.description,
}
for e in entries
]
def add_catalog(self, url: str, name: str | None = None) -> None:
"""Add a catalog source to the project-level config."""
self._validate_catalog_url(url)
config_path = self.project_root / ".specify" / "workflow-catalogs.yml"
data: dict[str, Any] = {"catalogs": []}
if config_path.exists():
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise WorkflowValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
data = raw
catalogs = data.get("catalogs", [])
if not isinstance(catalogs, list):
raise WorkflowValidationError(
"Catalog config 'catalogs' must be a list."
)
# Check for duplicate URL (guard against non-dict entries)
for cat in catalogs:
if isinstance(cat, dict) and cat.get("url") == url:
raise WorkflowValidationError(
f"Catalog URL already configured: {url}"
)
# Derive priority from the highest existing priority + 1
max_priority = max(
(cat.get("priority", 0) for cat in catalogs if isinstance(cat, dict)),
default=0,
)
catalogs.append(
{
"name": name or f"catalog-{len(catalogs) + 1}",
"url": url,
"priority": max_priority + 1,
"install_allowed": True,
"description": "",
}
)
data["catalogs"] = catalogs
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
def remove_catalog(self, index: int) -> str:
"""Remove a catalog source by index (0-based). Returns the removed name."""
config_path = self.project_root / ".specify" / "workflow-catalogs.yml"
if not config_path.exists():
raise WorkflowValidationError("No catalog config file found.")
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise WorkflowValidationError(
"Catalog config file is corrupted (expected a mapping)."
)
catalogs = data.get("catalogs", [])
if not isinstance(catalogs, list):
raise WorkflowValidationError(
"Catalog config 'catalogs' must be a list."
)
if index < 0 or index >= len(catalogs):
raise WorkflowValidationError(
f"Catalog index {index} out of range (0-{len(catalogs) - 1})."
)
removed = catalogs.pop(index)
data["catalogs"] = catalogs
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
if isinstance(removed, dict):
return removed.get("name", f"catalog-{index + 1}")
return f"catalog-{index + 1}"

View File

@@ -0,0 +1,778 @@
"""Workflow engine — loads, validates, and executes workflow YAML definitions.
The engine is the orchestrator that:
- Parses workflow YAML definitions
- Validates step configurations and requirements
- Executes steps sequentially, dispatching to the correct step type
- Manages state persistence for resume capability
- Handles control flow (branching, loops, fan-out/fan-in)
"""
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
from .base import RunStatus, StepContext, StepResult, StepStatus
# -- Workflow Definition --------------------------------------------------
class WorkflowDefinition:
"""Parsed and validated workflow YAML definition."""
def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> None:
self.data = data
self.source_path = source_path
workflow = data.get("workflow", {})
self.id: str = workflow.get("id", "")
self.name: str = workflow.get("name", "")
self.version: str = workflow.get("version", "0.0.0")
self.author: str = workflow.get("author", "")
self.description: str = workflow.get("description", "")
self.schema_version: str = data.get("schema_version", "1.0")
# Defaults
self.default_integration: str | None = workflow.get("integration")
self.default_model: str | None = workflow.get("model")
self.default_options: dict[str, Any] = workflow.get("options") or {}
if not isinstance(self.default_options, dict):
self.default_options = {}
# Requirements (declared but not yet enforced at runtime;
# enforcement is a planned enhancement)
self.requires: dict[str, Any] = data.get("requires", {})
# Inputs
self.inputs: dict[str, Any] = data.get("inputs", {})
# Steps
self.steps: list[dict[str, Any]] = data.get("steps", [])
@classmethod
def from_yaml(cls, path: Path) -> WorkflowDefinition:
"""Load a workflow definition from a YAML file."""
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
msg = f"Workflow YAML must be a mapping, got {type(data).__name__}."
raise ValueError(msg)
return cls(data, source_path=path)
@classmethod
def from_string(cls, content: str) -> WorkflowDefinition:
"""Load a workflow definition from a YAML string."""
data = yaml.safe_load(content)
if not isinstance(data, dict):
msg = f"Workflow YAML must be a mapping, got {type(data).__name__}."
raise ValueError(msg)
return cls(data)
# -- Workflow Validation --------------------------------------------------
# ID format: lowercase alphanumeric with hyphens
_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")
# Valid step types (matching STEP_REGISTRY keys)
def _get_valid_step_types() -> set[str]:
"""Return valid step types from the registry, with a built-in fallback."""
from . import STEP_REGISTRY
if STEP_REGISTRY:
return set(STEP_REGISTRY.keys())
return {
"command", "shell", "prompt", "gate", "if",
"switch", "while", "do-while", "fan-out", "fan-in",
}
def validate_workflow(definition: WorkflowDefinition) -> list[str]:
"""Validate a workflow definition and return a list of error messages.
An empty list means the workflow is valid.
"""
errors: list[str] = []
# -- Schema version ---------------------------------------------------
if definition.schema_version not in ("1.0", "1"):
errors.append(
f"Unsupported schema_version {definition.schema_version!r}. "
f"Expected '1.0'."
)
# -- Top-level fields -------------------------------------------------
if not definition.id:
errors.append("Workflow is missing 'workflow.id'.")
elif not _ID_PATTERN.match(definition.id):
errors.append(
f"Workflow ID {definition.id!r} must be lowercase alphanumeric "
f"with hyphens."
)
if not definition.name:
errors.append("Workflow is missing 'workflow.name'.")
if not definition.version:
errors.append("Workflow is missing 'workflow.version'.")
elif not re.match(r"^\d+\.\d+\.\d+$", definition.version):
errors.append(
f"Workflow version {definition.version!r} is not valid "
f"semantic versioning (expected X.Y.Z)."
)
# -- Inputs -----------------------------------------------------------
if not isinstance(definition.inputs, dict):
errors.append("'inputs' must be a mapping (or omitted).")
else:
for input_name, input_def in definition.inputs.items():
if not isinstance(input_def, dict):
errors.append(f"Input {input_name!r} must be a mapping.")
continue
input_type = input_def.get("type")
if input_type and input_type not in ("string", "number", "boolean"):
errors.append(
f"Input {input_name!r} has invalid type {input_type!r}. "
f"Must be 'string', 'number', or 'boolean'."
)
# -- Steps ------------------------------------------------------------
if not isinstance(definition.steps, list):
errors.append("'steps' must be a list.")
return errors
if not definition.steps:
errors.append("Workflow has no steps defined.")
seen_ids: set[str] = set()
_validate_steps(definition.steps, seen_ids, errors)
return errors
def _validate_steps(
steps: list[dict[str, Any]],
seen_ids: set[str],
errors: list[str],
) -> None:
"""Recursively validate a list of steps."""
from . import STEP_REGISTRY
for step_config in steps:
if not isinstance(step_config, dict):
errors.append(f"Step must be a mapping, got {type(step_config).__name__}.")
continue
step_id = step_config.get("id")
if not step_id:
errors.append("Step is missing 'id' field.")
continue
if ":" in step_id:
errors.append(
f"Step ID {step_id!r} contains ':' which is reserved "
f"for engine-generated nested IDs (parentId:childId)."
)
if step_id in seen_ids:
errors.append(f"Duplicate step ID {step_id!r}.")
seen_ids.add(step_id)
# Determine step type
step_type = step_config.get("type", "command")
if step_type not in _get_valid_step_types():
errors.append(
f"Step {step_id!r} has invalid type {step_type!r}."
)
continue
# Delegate to step-specific validation
step_impl = STEP_REGISTRY.get(step_type)
if step_impl:
step_errors = step_impl.validate(step_config)
errors.extend(step_errors)
# Recursively validate nested steps
for nested_key in ("then", "else", "steps"):
nested = step_config.get(nested_key)
if isinstance(nested, list):
_validate_steps(nested, seen_ids, errors)
# Validate switch cases
cases = step_config.get("cases")
if isinstance(cases, dict):
for _case_key, case_steps in cases.items():
if isinstance(case_steps, list):
_validate_steps(case_steps, seen_ids, errors)
# Validate switch default
default = step_config.get("default")
if isinstance(default, list):
_validate_steps(default, seen_ids, errors)
# Validate fan-out nested step (template — not added to seen_ids
# since the engine generates parentId:templateId:index at runtime)
fan_step = step_config.get("step")
if isinstance(fan_step, dict):
fan_errors: list[str] = []
_validate_steps([fan_step], set(), fan_errors)
errors.extend(fan_errors)
# -- Run State Persistence ------------------------------------------------
class RunState:
"""Manages workflow run state for persistence and resume."""
def __init__(
self,
run_id: str | None = None,
workflow_id: str = "",
project_root: Path | None = None,
) -> None:
self.run_id = run_id or str(uuid.uuid4())[:8]
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id):
msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only."
raise ValueError(msg)
self.workflow_id = workflow_id
self.project_root = project_root or Path(".")
self.status = RunStatus.CREATED
self.current_step_index = 0
self.current_step_id: str | None = None
self.step_results: dict[str, dict[str, Any]] = {}
self.inputs: dict[str, Any] = {}
self.created_at = datetime.now(timezone.utc).isoformat()
self.updated_at = self.created_at
self.log_entries: list[dict[str, Any]] = []
@property
def runs_dir(self) -> Path:
return self.project_root / ".specify" / "workflows" / "runs" / self.run_id
def save(self) -> None:
"""Persist current state to disk."""
self.updated_at = datetime.now(timezone.utc).isoformat()
runs_dir = self.runs_dir
runs_dir.mkdir(parents=True, exist_ok=True)
state_data = {
"run_id": self.run_id,
"workflow_id": self.workflow_id,
"status": self.status.value,
"current_step_index": self.current_step_index,
"current_step_id": self.current_step_id,
"step_results": self.step_results,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
with open(runs_dir / "state.json", "w", encoding="utf-8") as f:
json.dump(state_data, f, indent=2)
inputs_data = {"inputs": self.inputs}
with open(runs_dir / "inputs.json", "w", encoding="utf-8") as f:
json.dump(inputs_data, f, indent=2)
@classmethod
def load(cls, run_id: str, project_root: Path) -> RunState:
"""Load a run state from disk."""
runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id
state_path = runs_dir / "state.json"
if not state_path.exists():
msg = f"Run state not found: {state_path}"
raise FileNotFoundError(msg)
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
state = cls(
run_id=state_data["run_id"],
workflow_id=state_data["workflow_id"],
project_root=project_root,
)
state.status = RunStatus(state_data["status"])
state.current_step_index = state_data.get("current_step_index", 0)
state.current_step_id = state_data.get("current_step_id")
state.step_results = state_data.get("step_results", {})
state.created_at = state_data.get("created_at", "")
state.updated_at = state_data.get("updated_at", "")
inputs_path = runs_dir / "inputs.json"
if inputs_path.exists():
with open(inputs_path, encoding="utf-8") as f:
inputs_data = json.load(f)
state.inputs = inputs_data.get("inputs", {})
return state
def append_log(self, entry: dict[str, Any]) -> None:
"""Append a log entry to the run log."""
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
self.log_entries.append(entry)
runs_dir = self.runs_dir
runs_dir.mkdir(parents=True, exist_ok=True)
with open(runs_dir / "log.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
# -- Workflow Engine ------------------------------------------------------
class WorkflowEngine:
"""Orchestrator that loads, validates, and executes workflow definitions."""
def __init__(self, project_root: Path | None = None) -> None:
self.project_root = project_root or Path(".")
self.on_step_start: Any = None # Callable[[str, str], None] | None
def load_workflow(self, source: str | Path) -> WorkflowDefinition:
"""Load a workflow from an installed ID or a local YAML path.
Parameters
----------
source:
Either a workflow ID (looked up in the installed workflows
directory) or a path to a YAML file.
Returns
-------
A parsed ``WorkflowDefinition`` (not yet validated; call
``validate_workflow()`` or ``engine.validate()`` separately).
Raises
------
FileNotFoundError:
If the workflow file cannot be found.
ValueError:
If the workflow YAML is invalid.
"""
path = Path(source)
# Try as a direct file path first
if path.suffix in (".yml", ".yaml") and path.exists():
return WorkflowDefinition.from_yaml(path)
# Try as an installed workflow ID
installed_path = (
self.project_root
/ ".specify"
/ "workflows"
/ str(source)
/ "workflow.yml"
)
if installed_path.exists():
return WorkflowDefinition.from_yaml(installed_path)
msg = f"Workflow not found: {source}"
raise FileNotFoundError(msg)
def validate(self, definition: WorkflowDefinition) -> list[str]:
"""Validate a workflow definition."""
return validate_workflow(definition)
def execute(
self,
definition: WorkflowDefinition,
inputs: dict[str, Any] | None = None,
run_id: str | None = None,
) -> RunState:
"""Execute a workflow definition.
Parameters
----------
definition:
The validated workflow definition.
inputs:
User-provided input values.
run_id:
Optional run ID (auto-generated if not provided).
Returns
-------
The final ``RunState`` after execution completes (or pauses).
"""
from . import STEP_REGISTRY
state = RunState(
run_id=run_id,
workflow_id=definition.id,
project_root=self.project_root,
)
# Persist a copy of the workflow definition so resume can
# reload it even if the original source is no longer available
# (e.g. a local YAML path that was moved or deleted).
run_dir = self.project_root / ".specify" / "workflows" / "runs" / state.run_id
run_dir.mkdir(parents=True, exist_ok=True)
workflow_copy = run_dir / "workflow.yml"
import yaml
with open(workflow_copy, "w", encoding="utf-8") as f:
yaml.safe_dump(definition.data, f, sort_keys=False)
# Resolve inputs
resolved_inputs = self._resolve_inputs(definition, inputs or {})
state.inputs = resolved_inputs
state.status = RunStatus.RUNNING
state.save()
context = StepContext(
inputs=resolved_inputs,
default_integration=definition.default_integration,
default_model=definition.default_model,
default_options=definition.default_options,
project_root=str(self.project_root),
run_id=state.run_id,
)
# Execute steps
try:
self._execute_steps(definition.steps, context, state, STEP_REGISTRY)
except KeyboardInterrupt:
state.status = RunStatus.PAUSED
state.append_log({"event": "workflow_interrupted"})
state.save()
return state
except Exception as exc:
state.status = RunStatus.FAILED
state.append_log({"event": "workflow_failed", "error": str(exc)})
state.save()
raise
if state.status == RunStatus.RUNNING:
state.status = RunStatus.COMPLETED
state.append_log({"event": "workflow_finished", "status": state.status.value})
state.save()
return state
def resume(self, run_id: str) -> RunState:
"""Resume a paused or failed workflow run."""
state = RunState.load(run_id, self.project_root)
if state.status not in (RunStatus.PAUSED, RunStatus.FAILED):
msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}."
raise ValueError(msg)
# Load the workflow definition — try the persisted copy in the
# run directory first so resume works even if the original
# source (e.g. a local YAML path) is no longer available.
run_dir = self.project_root / ".specify" / "workflows" / "runs" / run_id
run_copy = run_dir / "workflow.yml"
if run_copy.exists():
definition = WorkflowDefinition.from_yaml(run_copy)
else:
definition = self.load_workflow(state.workflow_id)
# Restore context
context = StepContext(
inputs=state.inputs,
steps=state.step_results,
default_integration=definition.default_integration,
default_model=definition.default_model,
default_options=definition.default_options,
project_root=str(self.project_root),
run_id=state.run_id,
)
from . import STEP_REGISTRY
state.status = RunStatus.RUNNING
state.save()
# Resume from the current step — re-execute it so gates
# can prompt interactively again.
remaining_steps = definition.steps[state.current_step_index :]
step_offset = state.current_step_index
try:
self._execute_steps(
remaining_steps, context, state, STEP_REGISTRY,
step_offset=step_offset,
)
except KeyboardInterrupt:
state.status = RunStatus.PAUSED
state.append_log({"event": "workflow_interrupted"})
state.save()
return state
except Exception as exc:
state.status = RunStatus.FAILED
state.append_log({"event": "resume_failed", "error": str(exc)})
state.save()
raise
if state.status == RunStatus.RUNNING:
state.status = RunStatus.COMPLETED
state.append_log({"event": "workflow_finished", "status": state.status.value})
state.save()
return state
def _execute_steps(
self,
steps: list[dict[str, Any]],
context: StepContext,
state: RunState,
registry: dict[str, Any],
*,
step_offset: int = 0,
) -> None:
"""Execute a list of steps sequentially."""
for i, step_config in enumerate(steps):
step_id = step_config.get("id", f"step-{i}")
step_type = step_config.get("type", "command")
state.current_step_id = step_id
if step_offset >= 0:
state.current_step_index = step_offset + i
state.save()
state.append_log(
{"event": "step_started", "step_id": step_id, "type": step_type}
)
# Log progress — use the engine's on_step_start callback if set,
# otherwise stay silent (library-safe default).
label = step_config.get("command", "") or step_type
if self.on_step_start is not None:
self.on_step_start(step_id, label)
step_impl = registry.get(step_type)
if not step_impl:
state.status = RunStatus.FAILED
state.append_log(
{
"event": "step_failed",
"step_id": step_id,
"error": f"Unknown step type: {step_type!r}",
}
)
state.save()
return
result: StepResult = step_impl.execute(step_config, context)
# Record step results — prefer resolved values from step output
step_data = {
"integration": result.output.get("integration")
or step_config.get("integration")
or context.default_integration,
"model": result.output.get("model")
or step_config.get("model")
or context.default_model,
"options": result.output.get("options")
or step_config.get("options", {}),
"input": result.output.get("input")
or step_config.get("input", {}),
"output": result.output,
"status": result.status.value,
}
context.steps[step_id] = step_data
state.step_results[step_id] = step_data
state.append_log(
{
"event": "step_completed",
"step_id": step_id,
"status": result.status.value,
}
)
# Handle gate pauses
if result.status == StepStatus.PAUSED:
state.status = RunStatus.PAUSED
state.save()
return
# Handle failures
if result.status == StepStatus.FAILED:
# Gate abort (output.aborted) maps to ABORTED status
if result.output.get("aborted"):
state.status = RunStatus.ABORTED
state.append_log(
{
"event": "workflow_aborted",
"step_id": step_id,
}
)
else:
state.status = RunStatus.FAILED
state.append_log(
{
"event": "step_failed",
"step_id": step_id,
"error": result.error,
}
)
state.save()
return
# Execute nested steps (from control flow)
# NOTE: Nested steps run with step_offset=-1 so they don't
# update current_step_index. If a nested step pauses,
# resume will re-run the parent step and its nested body.
# A step-path stack for exact nested resume is a future
# enhancement.
if result.next_steps:
self._execute_steps(
result.next_steps, context, state, registry,
step_offset=-1,
)
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
return
# Loop iteration: while/do-while re-evaluate after body
if step_type in ("while", "do-while"):
from .expressions import evaluate_condition
max_iters = step_config.get("max_iterations")
if not isinstance(max_iters, int) or max_iters < 1:
max_iters = 10
condition = step_config.get("condition", False)
for _loop_iter in range(max_iters - 1):
if not evaluate_condition(condition, context):
break
# Namespace nested step IDs per iteration
iter_steps = []
for ns in result.next_steps:
ns_copy = dict(ns)
if "id" in ns_copy:
ns_copy["id"] = f"{step_id}:{ns_copy['id']}:{_loop_iter + 1}"
iter_steps.append(ns_copy)
self._execute_steps(
iter_steps, context, state, registry,
step_offset=-1,
)
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
return
# Fan-out: execute nested step template per item with unique IDs
if step_type == "fan-out":
items = result.output.get("items", [])
template = result.output.get("step_template", {})
if template and items:
fan_out_results = []
for item_idx, item_val in enumerate(result.output["items"]):
context.item = item_val
# Per-item ID: parentId:templateId:index
item_step = dict(template)
base_id = item_step.get("id", "item")
item_step["id"] = f"{step_id}:{base_id}:{item_idx}"
self._execute_steps(
[item_step], context, state, registry,
step_offset=-1,
)
# Collect per-item result for fan-in
item_result = context.steps.get(item_step["id"], {})
fan_out_results.append(item_result.get("output", {}))
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
break
context.item = None
# Preserve original output and add collected results
fan_out_output = dict(result.output)
fan_out_output["results"] = fan_out_results
context.steps[step_id]["output"] = fan_out_output
state.step_results[step_id]["output"] = fan_out_output
if state.status in (
RunStatus.PAUSED,
RunStatus.FAILED,
RunStatus.ABORTED,
):
return
else:
# Empty items or no template — normalize output
result.output["results"] = []
context.steps[step_id]["output"] = result.output
state.step_results[step_id]["output"] = result.output
def _resolve_inputs(
self,
definition: WorkflowDefinition,
provided: dict[str, Any],
) -> dict[str, Any]:
"""Resolve workflow inputs against definitions and provided values."""
resolved: dict[str, Any] = {}
for name, input_def in definition.inputs.items():
if not isinstance(input_def, dict):
continue
if name in provided:
resolved[name] = self._coerce_input(
name, provided[name], input_def
)
elif "default" in input_def:
resolved[name] = input_def["default"]
elif input_def.get("required", False):
msg = f"Required input {name!r} not provided."
raise ValueError(msg)
return resolved
@staticmethod
def _coerce_input(
name: str, value: Any, input_def: dict[str, Any]
) -> Any:
"""Coerce a provided input value to the declared type."""
input_type = input_def.get("type", "string")
enum_values = input_def.get("enum")
if input_type == "number":
try:
value = float(value)
if value == int(value):
value = int(value)
except (ValueError, TypeError):
msg = f"Input {name!r} expected a number, got {value!r}."
raise ValueError(msg) from None
elif input_type == "boolean":
if isinstance(value, str):
if value.lower() in ("true", "1", "yes"):
value = True
elif value.lower() in ("false", "0", "no"):
value = False
else:
msg = f"Input {name!r} expected a boolean, got {value!r}."
raise ValueError(msg)
if enum_values is not None and value not in enum_values:
msg = (
f"Input {name!r} value {value!r} not in allowed "
f"values: {enum_values}."
)
raise ValueError(msg)
return value
def list_runs(self) -> list[dict[str, Any]]:
"""List all workflow runs in the project."""
runs_dir = self.project_root / ".specify" / "workflows" / "runs"
if not runs_dir.exists():
return []
runs: list[dict[str, Any]] = []
for run_dir in sorted(runs_dir.iterdir()):
if not run_dir.is_dir():
continue
state_path = run_dir / "state.json"
if state_path.exists():
with open(state_path, encoding="utf-8") as f:
state_data = json.load(f)
runs.append(state_data)
return runs
class WorkflowAbortError(Exception):
"""Raised when a workflow is aborted (e.g., gate rejection)."""

View File

@@ -0,0 +1,300 @@
"""Sandboxed expression evaluator for workflow templates.
Provides a safe Jinja2 subset for evaluating expressions in workflow YAML.
No file I/O, no imports, no arbitrary code execution.
"""
from __future__ import annotations
import re
from typing import Any
# -- Custom filters -------------------------------------------------------
def _filter_default(value: Any, default_value: Any = "") -> Any:
"""Return *default_value* when *value* is ``None`` or empty string."""
if value is None or value == "":
return default_value
return value
def _filter_join(value: Any, separator: str = ", ") -> str:
"""Join a list into a string with *separator*."""
if isinstance(value, list):
return separator.join(str(v) for v in value)
return str(value)
def _filter_map(value: Any, attr: str) -> list[Any]:
"""Map a list of dicts to a specific attribute."""
if isinstance(value, list):
result = []
for item in value:
if isinstance(item, dict):
# Support dot notation: "result.status" → item["result"]["status"]
parts = attr.split(".")
v = item
for part in parts:
if isinstance(v, dict):
v = v.get(part)
else:
v = None
break
result.append(v)
else:
result.append(item)
return result
return []
def _filter_contains(value: Any, substring: str) -> bool:
"""Check if a string or list contains *substring*."""
if isinstance(value, str):
return substring in value
if isinstance(value, list):
return substring in value
return False
# -- Expression resolution ------------------------------------------------
_EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}")
def _resolve_dot_path(obj: Any, path: str) -> Any:
"""Resolve a dotted path like ``steps.specify.output.file`` against *obj*.
Supports dict key access and list indexing (e.g., ``task_list[0]``).
"""
parts = path.split(".")
current = obj
for part in parts:
# Handle list indexing: name[0]
idx_match = re.match(r"^([\w-]+)\[(\d+)\]$", part)
if idx_match:
key, idx = idx_match.group(1), int(idx_match.group(2))
if isinstance(current, dict):
current = current.get(key)
else:
return None
if isinstance(current, list) and 0 <= idx < len(current):
current = current[idx]
else:
return None
elif isinstance(current, dict):
current = current.get(part)
else:
return None
if current is None:
return None
return current
def _build_namespace(context: Any) -> dict[str, Any]:
"""Build the variable namespace from a StepContext."""
ns: dict[str, Any] = {}
if hasattr(context, "inputs"):
ns["inputs"] = context.inputs or {}
if hasattr(context, "steps"):
ns["steps"] = context.steps or {}
if hasattr(context, "item"):
ns["item"] = context.item
if hasattr(context, "fan_in"):
ns["fan_in"] = context.fan_in or {}
return ns
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
"""Evaluate a simple expression against the namespace.
Supports:
- Dot-path access: ``steps.specify.output.file``
- Comparisons: ``==``, ``!=``, ``>``, ``<``, ``>=``, ``<=``
- Boolean operators: ``and``, ``or``, ``not``
- ``in``, ``not in``
- Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| map('...')``
- String and numeric literals
"""
expr = expr.strip()
# String literal — check before pipes and operators so quoted strings
# containing | or operator keywords are not mis-parsed.
if (expr.startswith("'") and expr.endswith("'")) or (
expr.startswith('"') and expr.endswith('"')
):
return expr[1:-1]
# Handle pipe filters
if "|" in expr:
parts = expr.split("|", 1)
value = _evaluate_simple_expression(parts[0].strip(), namespace)
filter_expr = parts[1].strip()
# Parse filter name and argument
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
if filter_match:
fname = filter_match.group(1)
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
if fname == "default":
return _filter_default(value, farg)
if fname == "join":
return _filter_join(value, farg)
if fname == "map":
return _filter_map(value, farg)
if fname == "contains":
return _filter_contains(value, farg)
# Filter without args
filter_name = filter_expr.strip()
if filter_name == "default":
return _filter_default(value)
return value
# Boolean operators — parse 'or' first (lower precedence) so that
# 'a or b and c' is evaluated as 'a or (b and c)'.
if " or " in expr:
parts = expr.split(" or ", 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
return bool(left) or bool(right)
if " and " in expr:
parts = expr.split(" and ", 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
return bool(left) and bool(right)
if expr.startswith("not "):
inner = _evaluate_simple_expression(expr[4:].strip(), namespace)
return not bool(inner)
# Comparison operators (order matters — check multi-char ops first)
for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "):
if op in expr:
parts = expr.split(op, 1)
left = _evaluate_simple_expression(parts[0].strip(), namespace)
right = _evaluate_simple_expression(parts[1].strip(), namespace)
if op == "==":
return left == right
if op == "!=":
return left != right
if op == ">":
return _safe_compare(left, right, ">")
if op == "<":
return _safe_compare(left, right, "<")
if op == ">=":
return _safe_compare(left, right, ">=")
if op == "<=":
return _safe_compare(left, right, "<=")
if op == " in ":
return left in right if right is not None else False
if op == " not in ":
return left not in right if right is not None else True
# Numeric literal
try:
if "." in expr:
return float(expr)
return int(expr)
except (ValueError, TypeError):
pass
# Boolean literal
if expr.lower() == "true":
return True
if expr.lower() == "false":
return False
# Null
if expr.lower() in ("none", "null"):
return None
# List literal (simple)
if expr.startswith("[") and expr.endswith("]"):
inner = expr[1:-1].strip()
if not inner:
return []
items = [_evaluate_simple_expression(i.strip(), namespace) for i in inner.split(",")]
return items
# Variable reference (dot-path)
return _resolve_dot_path(namespace, expr)
def _safe_compare(left: Any, right: Any, op: str) -> bool:
"""Safely compare two values, coercing types when possible."""
try:
if isinstance(left, str):
left = float(left) if "." in left else int(left)
if isinstance(right, str):
right = float(right) if "." in right else int(right)
except (ValueError, TypeError):
return False
try:
if op == ">":
return left > right # type: ignore[operator]
if op == "<":
return left < right # type: ignore[operator]
if op == ">=":
return left >= right # type: ignore[operator]
if op == "<=":
return left <= right # type: ignore[operator]
except TypeError:
return False
return False
def evaluate_expression(template: str, context: Any) -> Any:
"""Evaluate a template string with ``{{ ... }}`` expressions.
If the entire string is a single expression, returns the raw value
(preserving type). Otherwise, substitutes each expression inline
and returns a string.
Parameters
----------
template:
The template string (e.g., ``"{{ steps.plan.output.task_count }}"``
or ``"Processed {{ inputs.feature_name }}"``.
context:
A ``StepContext`` or compatible object.
Returns
-------
The resolved value (any type for single-expression templates,
string for multi-expression or mixed templates).
"""
if not isinstance(template, str):
return template
namespace = _build_namespace(context)
# Single expression: return typed value
match = _EXPR_PATTERN.fullmatch(template.strip())
if match:
return _evaluate_simple_expression(match.group(1).strip(), namespace)
# Multi-expression: string interpolation
def _replacer(m: re.Match[str]) -> str:
val = _evaluate_simple_expression(m.group(1).strip(), namespace)
return str(val) if val is not None else ""
return _EXPR_PATTERN.sub(_replacer, template)
def evaluate_condition(condition: str, context: Any) -> bool:
"""Evaluate a condition expression and return a boolean.
Convenience wrapper around ``evaluate_expression`` that coerces
the result to bool.
"""
result = evaluate_expression(condition, context)
# Treat plain "false"/"true" strings as booleans so that
# condition: "false" (without {{ }}) behaves as expected.
if isinstance(result, str):
lower = result.lower()
if lower == "false":
return False
if lower == "true":
return True
return bool(result)

View File

@@ -0,0 +1 @@
"""Auto-discovery for built-in step types."""

View File

@@ -0,0 +1,155 @@
"""Command step — dispatches a Spec Kit command to an integration CLI."""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class CommandStep(StepBase):
"""Default step type — invokes a Spec Kit command via the integration CLI.
The command files (skills, markdown, TOML) are already installed in
the integration's directory on disk. This step tells the CLI to
execute the command by name (e.g. ``/speckit.specify`` or
``/speckit-specify``) rather than reading the file contents.
.. note::
CLI output is streamed to the terminal for live progress.
``output.exit_code`` is always captured and can be referenced
by later steps (e.g. ``{{ steps.specify.output.exit_code }}``).
Full ``stdout``/``stderr`` capture is a planned enhancement.
"""
type_key = "command"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
command = config.get("command", "")
input_data = config.get("input", {})
# Resolve expressions in input
resolved_input: dict[str, Any] = {}
for key, value in input_data.items():
resolved_input[key] = evaluate_expression(value, context)
# Resolve integration (step → workflow default → project default)
integration = config.get("integration") or context.default_integration
if integration and isinstance(integration, str) and "{{" in integration:
integration = evaluate_expression(integration, context)
# Resolve model
model = config.get("model") or context.default_model
if model and isinstance(model, str) and "{{" in model:
model = evaluate_expression(model, context)
# Merge options (workflow defaults ← step overrides)
options = dict(context.default_options)
step_options = config.get("options", {})
if step_options:
options.update(step_options)
# Attempt CLI dispatch
args_str = str(resolved_input.get("args", ""))
dispatch_result = self._try_dispatch(
command, integration, model, args_str, context
)
output: dict[str, Any] = {
"command": command,
"integration": integration,
"model": model,
"options": options,
"input": resolved_input,
}
if dispatch_result is not None:
output["exit_code"] = dispatch_result["exit_code"]
output["stdout"] = dispatch_result["stdout"]
output["stderr"] = dispatch_result["stderr"]
output["dispatched"] = True
if dispatch_result["exit_code"] != 0:
return StepResult(
status=StepStatus.FAILED,
output=output,
error=dispatch_result["stderr"] or f"Command exited with code {dispatch_result['exit_code']}",
)
return StepResult(
status=StepStatus.COMPLETED,
output=output,
)
else:
output["exit_code"] = 1
output["dispatched"] = False
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
f"Cannot dispatch command {command!r}: "
f"integration {integration!r} CLI not found or not installed. "
f"Install the CLI tool or check 'specify integration list'."
),
)
@staticmethod
def _try_dispatch(
command: str,
integration_key: str | None,
model: str | None,
args: str,
context: StepContext,
) -> dict[str, Any] | None:
"""Invoke *command* by name through the integration CLI.
The integration's ``dispatch_command`` builds the native
slash-command invocation (e.g. ``/speckit.specify`` for
markdown agents, ``/speckit-specify`` for skills agents),
then executes the CLI non-interactively.
Returns the dispatch result dict, or ``None`` if dispatch is
not possible (integration not found, CLI not installed, or
dispatch not supported).
"""
if not integration_key:
return None
try:
from specify_cli.integrations import get_integration
except ImportError:
return None
impl = get_integration(integration_key)
if impl is None:
return None
# Check if the integration supports CLI dispatch
if impl.build_exec_args("test") is None:
return None
# Check if the CLI tool is actually installed
if not shutil.which(impl.key):
return None
project_root = Path(context.project_root) if context.project_root else None
try:
return impl.dispatch_command(
command,
args=args,
project_root=project_root,
model=model,
)
except (NotImplementedError, OSError):
return None
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "command" not in config:
errors.append(
f"Command step {config.get('id', '?')!r} is missing 'command' field."
)
return errors

View File

@@ -0,0 +1,61 @@
"""Do-While loop step — execute at least once, then repeat while condition is truthy."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
class DoWhileStep(StepBase):
"""Execute body at least once, then check condition.
Continues while condition is truthy. ``max_iterations`` is an
optional safety cap (defaults to 10 if omitted).
The first invocation always returns the nested steps for execution.
The engine re-evaluates ``step_config['condition']`` after each
iteration to decide whether to loop again.
"""
type_key = "do-while"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
max_iterations = config.get("max_iterations")
if max_iterations is None:
max_iterations = 10
nested_steps = config.get("steps", [])
condition = config.get("condition", "false")
# Always execute body at least once; the engine layer evaluates
# `condition` after each iteration to decide whether to loop.
return StepResult(
status=StepStatus.COMPLETED,
output={
"condition": condition,
"max_iterations": max_iterations,
"loop_type": "do-while",
},
next_steps=nested_steps,
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "condition" not in config:
errors.append(
f"Do-while step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
if not isinstance(max_iter, int) or max_iter < 1:
errors.append(
f"Do-while step {config.get('id', '?')!r}: "
f"'max_iterations' must be an integer >= 1."
)
nested = config.get("steps", [])
if not isinstance(nested, list):
errors.append(
f"Do-while step {config.get('id', '?')!r}: 'steps' must be a list."
)
return errors

View File

@@ -0,0 +1,61 @@
"""Fan-in step — join point for parallel steps."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class FanInStep(StepBase):
"""Join point that aggregates results from ``wait_for:`` steps.
Reads completed step outputs from ``context.steps`` and collects
them into ``output.results``. Does not block; relies on the
engine executing steps sequentially.
"""
type_key = "fan-in"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
wait_for = config.get("wait_for", [])
output_config = config.get("output") or {}
if not isinstance(output_config, dict):
output_config = {}
# Collect results from referenced steps
results = []
for step_id in wait_for:
step_data = context.steps.get(step_id, {})
results.append(step_data.get("output", {}))
# Resolve output expressions with fan_in in context
prev_fan_in = getattr(context, "fan_in", None)
context.fan_in = {"results": results}
resolved_output: dict[str, Any] = {"results": results}
try:
for key, expr in output_config.items():
if isinstance(expr, str) and "{{" in expr:
resolved_output[key] = evaluate_expression(expr, context)
else:
resolved_output[key] = expr
finally:
# Restore previous fan_in state even if evaluation fails
context.fan_in = prev_fan_in
return StepResult(
status=StepStatus.COMPLETED,
output=resolved_output,
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
wait_for = config.get("wait_for", [])
if not isinstance(wait_for, list) or not wait_for:
errors.append(
f"Fan-in step {config.get('id', '?')!r}: "
f"'wait_for' must be a non-empty list of step IDs."
)
return errors

View File

@@ -0,0 +1,58 @@
"""Fan-out step — dispatch a step template over a collection."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class FanOutStep(StepBase):
"""Dispatch a step template for each item in a collection.
The engine executes the nested ``step:`` template once per item,
setting ``context.item`` for each iteration. Execution is
currently sequential; ``max_concurrency`` is accepted but not
enforced.
"""
type_key = "fan-out"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
items_expr = config.get("items", "[]")
items = evaluate_expression(items_expr, context)
if not isinstance(items, list):
items = []
max_concurrency = config.get("max_concurrency", 1)
step_template = config.get("step", {})
return StepResult(
status=StepStatus.COMPLETED,
output={
"items": items,
"max_concurrency": max_concurrency,
"step_template": step_template,
"item_count": len(items),
},
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "items" not in config:
errors.append(
f"Fan-out step {config.get('id', '?')!r} is missing "
f"'items' field."
)
if "step" not in config:
errors.append(
f"Fan-out step {config.get('id', '?')!r} is missing "
f"'step' field (nested step template)."
)
step = config.get("step")
if step is not None and not isinstance(step, dict):
errors.append(
f"Fan-out step {config.get('id', '?')!r}: 'step' must be a mapping."
)
return errors

View File

@@ -0,0 +1,121 @@
"""Gate step — human review gate."""
from __future__ import annotations
import sys
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class GateStep(StepBase):
"""Interactive review gate.
When running in an interactive terminal, prompts the user to choose
an option (e.g. approve / reject). Falls back to ``PAUSED`` when
stdin is not a TTY (CI, piped input) so the run can be resumed
later with ``specify workflow resume``.
The user's choice is stored in ``output.choice``. ``on_reject``
controls abort / skip behaviour.
"""
type_key = "gate"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
message = config.get("message", "Review required.")
if isinstance(message, str) and "{{" in message:
message = evaluate_expression(message, context)
options = config.get("options", ["approve", "reject"])
on_reject = config.get("on_reject", "abort")
show_file = config.get("show_file")
if show_file and isinstance(show_file, str) and "{{" in show_file:
show_file = evaluate_expression(show_file, context)
output = {
"message": message,
"options": options,
"on_reject": on_reject,
"show_file": show_file,
"choice": None,
}
# Non-interactive: pause for later resume
if not sys.stdin.isatty():
return StepResult(status=StepStatus.PAUSED, output=output)
# Interactive: prompt the user
choice = self._prompt(message, options)
output["choice"] = choice
if choice in ("reject", "abort"):
if on_reject == "abort":
output["aborted"] = True
return StepResult(
status=StepStatus.FAILED,
output=output,
error=f"Gate rejected by user at step {config.get('id', '?')!r}",
)
if on_reject == "retry":
# Pause so the next resume re-executes this gate
return StepResult(status=StepStatus.PAUSED, output=output)
# on_reject == "skip" → completed, downstream steps decide
return StepResult(status=StepStatus.COMPLETED, output=output)
return StepResult(status=StepStatus.COMPLETED, output=output)
@staticmethod
def _prompt(message: str, options: list[str]) -> str:
"""Display gate message and prompt for a choice."""
print("\n ┌─ Gate ─────────────────────────────────────")
print(f"{message}")
print("")
for i, opt in enumerate(options, 1):
print(f" │ [{i}] {opt}")
print(" └────────────────────────────────────────────")
while True:
try:
raw = input(f" Choose [1-{len(options)}]: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return options[-1] # default to last (usually reject)
if raw.isdigit() and 1 <= int(raw) <= len(options):
return options[int(raw) - 1]
# Also accept the option name directly
if raw.lower() in [o.lower() for o in options]:
return next(o for o in options if o.lower() == raw.lower())
print(f" Invalid choice. Enter 1-{len(options)} or an option name.")
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "message" not in config:
errors.append(
f"Gate step {config.get('id', '?')!r} is missing 'message' field."
)
options = config.get("options", ["approve", "reject"])
if not isinstance(options, list) or not options:
errors.append(
f"Gate step {config.get('id', '?')!r}: 'options' must be a non-empty list."
)
elif not all(isinstance(o, str) for o in options):
errors.append(
f"Gate step {config.get('id', '?')!r}: all options must be strings."
)
on_reject = config.get("on_reject", "abort")
if on_reject not in ("abort", "skip", "retry"):
errors.append(
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
f"'abort', 'skip', or 'retry'."
)
if on_reject in ("abort", "retry") and isinstance(options, list):
reject_choices = {"reject", "abort"}
if not any(o.lower() in reject_choices for o in options):
errors.append(
f"Gate step {config.get('id', '?')!r}: on_reject={on_reject!r} "
f"but options has no 'reject' or 'abort' choice."
)
return errors

View File

@@ -0,0 +1,55 @@
"""If/Then/Else step — conditional branching."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_condition
class IfThenStep(StepBase):
"""Branch based on a boolean condition expression.
Both ``then:`` and ``else:`` contain inline step arrays — full step
definitions, not ID references.
"""
type_key = "if"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
condition = config.get("condition", False)
result = evaluate_condition(condition, context)
if result:
branch = config.get("then", [])
else:
branch = config.get("else", [])
return StepResult(
status=StepStatus.COMPLETED,
output={"condition_result": result},
next_steps=branch,
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "condition" not in config:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'condition' field."
)
if "then" not in config:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'then' field."
)
then_branch = config.get("then", [])
if not isinstance(then_branch, list):
errors.append(
f"If step {config.get('id', '?')!r}: 'then' must be a list of steps."
)
else_branch = config.get("else", [])
if else_branch and not isinstance(else_branch, list):
errors.append(
f"If step {config.get('id', '?')!r}: 'else' must be a list of steps."
)
return errors

View File

@@ -0,0 +1,156 @@
"""Prompt step — sends an arbitrary prompt to an integration CLI."""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class PromptStep(StepBase):
"""Send a free-form prompt to an integration CLI.
Unlike ``CommandStep`` which invokes an installed Spec Kit command
by name (e.g. ``/speckit.specify`` or ``/speckit-specify``),
``PromptStep`` sends an arbitrary inline ``prompt:`` string
directly to the CLI. This is useful for ad-hoc instructions
that don't map to a registered command.
.. note::
CLI output is streamed to the terminal for live progress.
``output.exit_code`` is always captured and can be referenced
by later steps. Full response text capture is a planned
enhancement.
Example YAML::
- id: review-security
type: prompt
prompt: "Review {{ inputs.file }} for security vulnerabilities"
integration: claude
"""
type_key = "prompt"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
prompt_template = config.get("prompt", "")
prompt = evaluate_expression(prompt_template, context)
if not isinstance(prompt, str):
prompt = str(prompt)
# Resolve integration (step → workflow default)
integration = config.get("integration") or context.default_integration
if integration and isinstance(integration, str) and "{{" in integration:
integration = evaluate_expression(integration, context)
# Resolve model
model = config.get("model") or context.default_model
if model and isinstance(model, str) and "{{" in model:
model = evaluate_expression(model, context)
# Attempt CLI dispatch
dispatch_result = self._try_dispatch(
prompt, integration, model, context
)
output: dict[str, Any] = {
"prompt": prompt,
"integration": integration,
"model": model,
}
if dispatch_result is not None:
output["exit_code"] = dispatch_result["exit_code"]
output["stdout"] = dispatch_result["stdout"]
output["stderr"] = dispatch_result["stderr"]
output["dispatched"] = True
if dispatch_result["exit_code"] != 0:
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
dispatch_result["stderr"]
or f"Prompt exited with code {dispatch_result['exit_code']}"
),
)
return StepResult(
status=StepStatus.COMPLETED,
output=output,
)
else:
output["exit_code"] = 1
output["dispatched"] = False
return StepResult(
status=StepStatus.FAILED,
output=output,
error=(
f"Cannot dispatch prompt: "
f"integration {integration!r} "
f"CLI not found or not installed."
),
)
@staticmethod
def _try_dispatch(
prompt: str,
integration_key: str | None,
model: str | None,
context: StepContext,
) -> dict[str, Any] | None:
"""Dispatch *prompt* directly through the integration CLI."""
if not integration_key or not prompt:
return None
try:
from specify_cli.integrations import get_integration
except ImportError:
return None
impl = get_integration(integration_key)
if impl is None:
return None
exec_args = impl.build_exec_args(prompt, model=model, output_json=False)
if exec_args is None:
return None
if not shutil.which(impl.key):
return None
import subprocess
project_root = (
Path(context.project_root) if context.project_root else Path.cwd()
)
try:
result = subprocess.run(
exec_args,
text=True,
cwd=str(project_root),
)
return {
"exit_code": result.returncode,
"stdout": "",
"stderr": "",
}
except KeyboardInterrupt:
return {
"exit_code": 130,
"stdout": "",
"stderr": "Interrupted by user",
}
except OSError:
return None
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "prompt" not in config:
errors.append(
f"Prompt step {config.get('id', '?')!r} is missing 'prompt' field."
)
return errors

View File

@@ -0,0 +1,75 @@
"""Shell step — run a local shell command."""
from __future__ import annotations
import subprocess
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class ShellStep(StepBase):
"""Run a local shell command (non-agent).
Captures exit code and stdout/stderr.
"""
type_key = "shell"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
run_cmd = config.get("run", "")
if isinstance(run_cmd, str) and "{{" in run_cmd:
run_cmd = evaluate_expression(run_cmd, context)
run_cmd = str(run_cmd)
cwd = context.project_root or "."
# NOTE: shell=True is required to support pipes, redirects, and
# multi-command expressions in workflow YAML. Workflow authors
# control commands; catalog-installed workflows should be reviewed
# before use (see PUBLISHING.md for security guidance).
try:
proc = subprocess.run(
run_cmd,
shell=True,
capture_output=True,
text=True,
cwd=cwd,
timeout=300,
)
output = {
"exit_code": proc.returncode,
"stdout": proc.stdout,
"stderr": proc.stderr,
}
if proc.returncode != 0:
return StepResult(
status=StepStatus.FAILED,
error=f"Shell command exited with code {proc.returncode}.",
output=output,
)
return StepResult(
status=StepStatus.COMPLETED,
output=output,
)
except subprocess.TimeoutExpired:
return StepResult(
status=StepStatus.FAILED,
error="Shell command timed out after 300 seconds.",
output={"exit_code": -1, "stdout": "", "stderr": "timeout"},
)
except OSError as exc:
return StepResult(
status=StepStatus.FAILED,
error=f"Shell command failed: {exc}",
output={"exit_code": -1, "stdout": "", "stderr": str(exc)},
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "run" not in config:
errors.append(
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
)
return errors

View File

@@ -0,0 +1,70 @@
"""Switch step — multi-branch dispatch."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_expression
class SwitchStep(StepBase):
"""Multi-branch dispatch on an expression.
Evaluates ``expression:`` once, matches against ``cases:`` keys
(exact match, string-coerced). Falls through to ``default:`` if
no case matches.
"""
type_key = "switch"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
expression = config.get("expression", "")
value = evaluate_expression(expression, context)
# String-coerce for matching
str_value = str(value) if value is not None else ""
cases = config.get("cases", {})
for case_key, case_steps in cases.items():
if str(case_key) == str_value:
return StepResult(
status=StepStatus.COMPLETED,
output={"matched_case": str(case_key), "expression_value": value},
next_steps=case_steps,
)
# Default fallback
default_steps = config.get("default", [])
return StepResult(
status=StepStatus.COMPLETED,
output={"matched_case": "__default__", "expression_value": value},
next_steps=default_steps,
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "expression" not in config:
errors.append(
f"Switch step {config.get('id', '?')!r} is missing "
f"'expression' field."
)
cases = config.get("cases", {})
if not isinstance(cases, dict):
errors.append(
f"Switch step {config.get('id', '?')!r}: 'cases' must be a mapping."
)
else:
for key, val in cases.items():
if not isinstance(val, list):
errors.append(
f"Switch step {config.get('id', '?')!r}: "
f"case {key!r} must be a list of steps."
)
default = config.get("default")
if default is not None and not isinstance(default, list):
errors.append(
f"Switch step {config.get('id', '?')!r}: "
f"'default' must be a list of steps."
)
return errors

View File

@@ -0,0 +1,68 @@
"""While loop step — repeat while condition is truthy."""
from __future__ import annotations
from typing import Any
from specify_cli.workflows.base import StepBase, StepContext, StepResult, StepStatus
from specify_cli.workflows.expressions import evaluate_condition
class WhileStep(StepBase):
"""Repeat nested steps while condition is truthy.
Evaluates condition *before* each iteration. If falsy on first
check, the body never runs. ``max_iterations`` is an optional
safety cap (defaults to 10 if omitted).
"""
type_key = "while"
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
condition = config.get("condition", False)
max_iterations = config.get("max_iterations")
if max_iterations is None:
max_iterations = 10
nested_steps = config.get("steps", [])
result = evaluate_condition(condition, context)
if result:
return StepResult(
status=StepStatus.COMPLETED,
output={
"condition_result": True,
"max_iterations": max_iterations,
"loop_type": "while",
},
next_steps=nested_steps,
)
return StepResult(
status=StepStatus.COMPLETED,
output={
"condition_result": False,
"max_iterations": max_iterations,
"loop_type": "while",
},
)
def validate(self, config: dict[str, Any]) -> list[str]:
errors = super().validate(config)
if "condition" not in config:
errors.append(
f"While step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
max_iter = config.get("max_iterations")
if max_iter is not None:
if not isinstance(max_iter, int) or max_iter < 1:
errors.append(
f"While step {config.get('id', '?')!r}: "
f"'max_iterations' must be an integer >= 1."
)
nested = config.get("steps", [])
if not isinstance(nested, list):
errors.append(
f"While step {config.get('id', '?')!r}: 'steps' must be a list."
)
return errors

View File

@@ -587,3 +587,40 @@ 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

View File

@@ -245,6 +245,9 @@ 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):

View File

@@ -347,6 +347,11 @@ 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):

View File

@@ -84,7 +84,9 @@ class TomlIntegrationTests:
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.commands_dest(tmp_path)
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
assert expected_dir.exists(), (
f"Expected directory {expected_dir} was not created"
)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
@@ -134,6 +136,12 @@ class TomlIntegrationTests:
# At least one file should contain {{args}} from the {ARGS} placeholder
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
assert has_args, "No TOML command file contains {{args}} placeholder"
has_dollar_args = any(
"$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files
)
assert not has_dollar_args, (
"TOML command still contains $ARGUMENTS instead of {{args}}"
)
@pytest.mark.parametrize(
("frontmatter", "expected"),
@@ -156,19 +164,13 @@ class TomlIntegrationTests:
),
],
)
def test_toml_extract_description_supports_block_scalars(self, frontmatter, expected):
def test_toml_extract_description_supports_block_scalars(
self, frontmatter, expected
):
assert TomlIntegration._extract_description(frontmatter) == expected
def test_split_frontmatter_ignores_indented_delimiters(self):
content = (
"---\n"
"description: |\n"
" line one\n"
" ---\n"
" line two\n"
"---\n"
"Body\n"
)
content = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n"
frontmatter, body = TomlIntegration._split_frontmatter(content)
@@ -205,7 +207,7 @@ class TomlIntegrationTests:
assert "---" not in parsed["prompt"]
def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch):
"""Multiline body ending with `"` must not produce `""""` (#2113)."""
"""Multiline body ending with a double quote must not produce an ambiguous TOML multiline-string closing delimiter (#2113)."""
i = get_integration(self.KEY)
template = tmp_path / "sample.md"
template.write_text(
@@ -230,7 +232,9 @@ class TomlIntegrationTests:
assert '"""\n' in raw, "body must use multiline basic string"
parsed = tomllib.loads(raw)
assert parsed["prompt"].endswith('specified?"')
assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline"
assert not parsed["prompt"].endswith("\n"), (
"parsed value must not gain a trailing newline"
)
def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch):
"""Body containing `\"\"\"` and ending with `'` falls back to escaped basic string."""
@@ -254,11 +258,15 @@ class TomlIntegrationTests:
assert len(cmd_files) == 1
raw = cmd_files[0].read_text(encoding="utf-8")
assert "''''" not in raw, "literal string must not produce ambiguous closing quotes"
assert "''''" not in raw, (
"literal string must not produce ambiguous closing quotes"
)
parsed = tomllib.loads(raw)
assert parsed["prompt"].endswith("'single'")
assert '"""triple"""' in parsed["prompt"]
assert not parsed["prompt"].endswith("\n"), "parsed value must not gain a trailing newline"
assert not parsed["prompt"].endswith("\n"), (
"parsed value must not gain a trailing newline"
)
def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch):
"""Body NOT ending with `"` keeps closing `\"\"\"` inline (no extra newline)."""
@@ -284,8 +292,9 @@ class TomlIntegrationTests:
raw = cmd_files[0].read_text(encoding="utf-8")
parsed = tomllib.loads(raw)
assert parsed["prompt"] == "Line one\nPlain body content"
assert raw.rstrip().endswith('content"""'), \
assert raw.rstrip().endswith('content"""'), (
"closing delimiter should be inline when body does not end with a quote"
)
def test_toml_is_valid(self, tmp_path):
"""Every generated TOML file must parse without errors."""
@@ -354,7 +363,14 @@ class TomlIntegrationTests:
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh"
sh = (
tmp_path
/ ".specify"
/ "integrations"
/ self.KEY
/ "scripts"
/ "update-context.sh"
)
assert os.access(sh, os.X_OK)
# -- CLI auto-promote -------------------------------------------------
@@ -369,10 +385,20 @@ class TomlIntegrationTests:
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
result = runner.invoke(
app,
[
"init",
"--here",
"--ai",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
@@ -390,13 +416,25 @@ class TomlIntegrationTests:
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
assert result.exit_code == 0, (
f"init --integration {self.KEY} failed: {result.output}"
)
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
@@ -406,8 +444,15 @@ class TomlIntegrationTests:
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"specify",
"tasks",
"taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
@@ -425,26 +470,44 @@ class TomlIntegrationTests:
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
# Framework files
files.append(f".specify/integration.json")
files.append(f".specify/init-options.json")
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(f".specify/integrations/speckit.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
"setup-plan.sh", "update-agent-context.sh"]:
for name in [
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"update-agent-context.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
"setup-plan.ps1", "update-agent-context.ps1"]:
for name in [
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"update-agent-context.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")
for name in ["agent-file-template.md", "checklist-template.md",
"constitution-template.md", "plan-template.md",
"spec-template.md", "tasks-template.md"]:
for name in [
"agent-file-template.md",
"checklist-template.md",
"constitution-template.md",
"plan-template.md",
"spec-template.md",
"tasks-template.md",
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
# 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):
@@ -457,15 +520,26 @@ class TomlIntegrationTests:
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "sh",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file())
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
@@ -482,15 +556,26 @@ class TomlIntegrationTests:
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(app, [
"init", "--here", "--integration", self.KEY, "--script", "ps",
"--no-git", "--ignore-agent-tools",
], catch_exceptions=False)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"ps",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(p.relative_to(project).as_posix()
for p in project.rglob("*") if p.is_file())
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"

View File

@@ -0,0 +1,462 @@
"""Reusable test mixin for standard YamlIntegration subclasses.
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
logic from ``YamlIntegrationTests``.
Mirrors ``TomlIntegrationTests`` closely — same test structure,
adapted for YAML recipe output format.
"""
import os
import yaml
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
from specify_cli.integrations.base import YamlIntegration
from specify_cli.integrations.manifest import IntegrationManifest
class YamlIntegrationTests:
"""Mixin — set class-level constants and inherit these tests.
Required class attrs on subclass::
KEY: str — integration registry key
FOLDER: str — e.g. ".goose/"
COMMANDS_SUBDIR: str — e.g. "recipes"
REGISTRAR_DIR: str — e.g. ".goose/recipes"
CONTEXT_FILE: str — e.g. "AGENTS.md"
"""
KEY: str
FOLDER: str
COMMANDS_SUBDIR: str
REGISTRAR_DIR: str
CONTEXT_FILE: str
# -- Registration -----------------------------------------------------
def test_registered(self):
assert self.KEY in INTEGRATION_REGISTRY
assert get_integration(self.KEY) is not None
def test_is_yaml_integration(self):
assert isinstance(get_integration(self.KEY), YamlIntegration)
# -- Config -----------------------------------------------------------
def test_config_folder(self):
i = get_integration(self.KEY)
assert i.config["folder"] == self.FOLDER
def test_config_commands_subdir(self):
i = get_integration(self.KEY)
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "yaml"
assert i.registrar_config["args"] == "{{args}}"
assert i.registrar_config["extension"] == ".yaml"
def test_context_file(self):
i = get_integration(self.KEY)
assert i.context_file == self.CONTEXT_FILE
# -- Setup / teardown -------------------------------------------------
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit.")
assert f.name.endswith(".yaml")
def test_setup_writes_to_correct_directory(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
expected_dir = i.commands_dest(tmp_path)
assert expected_dir.exists(), (
f"Expected directory {expected_dir} was not created"
)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0, "No command files were created"
for f in cmd_files:
assert f.resolve().parent == expected_dir.resolve(), (
f"{f} is not under {expected_dir}"
)
def test_templates_are_processed(self, tmp_path):
"""Command files must have placeholders replaced."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) > 0
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
def test_yaml_has_title(self, tmp_path):
"""Every YAML recipe should have a title field."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "title:" in content, f"{f.name} missing title field"
def test_yaml_has_prompt(self, tmp_path):
"""Every YAML recipe should have a prompt block scalar."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
assert "prompt: |" in content, f"{f.name} missing prompt block scalar"
def test_yaml_uses_correct_arg_placeholder(self, tmp_path):
"""YAML recipes must use {{args}} placeholder."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
assert has_args, "No YAML recipe contains {{args}} placeholder"
has_dollar_args = any(
"$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files
)
assert not has_dollar_args, (
"YAML recipe still contains $ARGUMENTS instead of {{args}}"
)
def test_yaml_is_valid(self, tmp_path):
"""Every generated YAML file must parse without errors."""
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
content = f.read_text(encoding="utf-8")
# Strip trailing source comment before parsing
lines = content.split("\n")
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
try:
parsed = yaml.safe_load("\n".join(yaml_lines))
except Exception as exc:
raise AssertionError(f"{f.name} is not valid YAML: {exc}") from exc
assert "prompt" in parsed, f"{f.name} parsed YAML has no 'prompt' key"
assert "title" in parsed, f"{f.name} parsed YAML has no 'title' key"
def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch):
i = get_integration(self.KEY)
template = tmp_path / "sample.md"
template.write_text(
"---\n"
"description: Summary line one\n"
"scripts:\n"
" sh: scripts/bash/example.sh\n"
"---\n"
"Body line one\n"
"Body line two\n",
encoding="utf-8",
)
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
cmd_files = [f for f in created if "scripts" not in f.parts]
assert len(cmd_files) == 1
content = cmd_files[0].read_text(encoding="utf-8")
# Strip source comment for parsing
lines = content.split("\n")
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
parsed = yaml.safe_load("\n".join(yaml_lines))
assert "description:" not in parsed["prompt"]
assert "scripts:" not in parsed["prompt"]
assert "---" not in parsed["prompt"]
def test_all_files_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
for f in created:
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
assert rel in m.files, f"{rel} not tracked in manifest"
def test_install_uninstall_roundtrip(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
assert len(created) > 0
m.save()
for f in created:
assert f.exists()
removed, skipped = i.uninstall(tmp_path, m)
assert len(removed) == len(created)
assert skipped == []
def test_modified_file_survives_uninstall(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.install(tmp_path, m)
m.save()
modified_file = created[0]
modified_file.write_text("user modified this", encoding="utf-8")
removed, skipped = i.uninstall(tmp_path, m)
assert modified_file.exists()
assert modified_file in skipped
# -- Scripts ----------------------------------------------------------
def test_setup_installs_update_context_scripts(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts"
assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}"
assert (scripts_dir / "update-context.sh").exists()
assert (scripts_dir / "update-context.ps1").exists()
def test_scripts_tracked_in_manifest(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
script_rels = [k for k in m.files if "update-context" in k]
assert len(script_rels) >= 2
def test_sh_script_is_executable(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m)
sh = (
tmp_path
/ ".specify"
/ "integrations"
/ self.KEY
/ "scripts"
/ "update-context.sh"
)
assert os.access(sh, os.X_OK)
# -- CLI auto-promote -------------------------------------------------
def test_ai_flag_auto_promotes(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"promote-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--ai",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
def test_integration_flag_creates_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"int-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, (
f"init --integration {self.KEY} failed: {result.output}"
)
i = get_integration(self.KEY)
cmd_dir = i.commands_dest(project)
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
commands = sorted(cmd_dir.glob("speckit.*.yaml"))
assert len(commands) > 0, f"No command files in {cmd_dir}"
# -- Complete file inventory ------------------------------------------
COMMAND_STEMS = [
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"specify",
"tasks",
"taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:
"""Build the expected file list for this integration + script variant."""
i = get_integration(self.KEY)
cmd_dir = i.registrar_config["dir"]
files = []
# Command files (.yaml)
for stem in self.COMMAND_STEMS:
files.append(f"{cmd_dir}/speckit.{stem}.yaml")
# Integration scripts
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1")
files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh")
# Framework files
files.append(".specify/integration.json")
files.append(".specify/init-options.json")
files.append(f".specify/integrations/{self.KEY}.manifest.json")
files.append(".specify/integrations/speckit.manifest.json")
if script_variant == "sh":
for name in [
"check-prerequisites.sh",
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"update-agent-context.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in [
"check-prerequisites.ps1",
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"update-agent-context.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")
for name in [
"agent-file-template.md",
"checklist-template.md",
"constitution-template.md",
"plan-template.md",
"spec-template.md",
"tasks-template.md",
]:
files.append(f".specify/templates/{name}")
files.append(".specify/memory/constitution.md")
# 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):
"""Every file produced by specify init --integration <key> --script sh."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-sh-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("sh")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)
def test_complete_file_inventory_ps(self, tmp_path):
"""Every file produced by specify init --integration <key> --script ps."""
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / f"inventory-ps-{self.KEY}"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
result = CliRunner().invoke(
app,
[
"init",
"--here",
"--integration",
self.KEY,
"--script",
"ps",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
actual = sorted(
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
)
expected = self._expected_files("ps")
assert actual == expected, (
f"Missing: {sorted(set(expected) - set(actual))}\n"
f"Extra: {sorted(set(actual) - set(expected))}"
)

View File

@@ -199,6 +199,8 @@ 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"
@@ -259,6 +261,8 @@ 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"

View File

@@ -1,11 +1,28 @@
"""Tests for CursorAgentIntegration."""
from .test_integration_base_markdown import MarkdownIntegrationTests
from .test_integration_base_skills import SkillsIntegrationTests
class TestCursorAgentIntegration(MarkdownIntegrationTests):
class TestCursorAgentIntegration(SkillsIntegrationTests):
KEY = "cursor-agent"
FOLDER = ".cursor/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".cursor/commands"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".cursor/skills"
CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
class TestCursorAgentAutoPromote:
"""--ai cursor-agent auto-promotes to integration path."""
def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path):
"""--ai cursor-agent should work the same as --integration cursor-agent."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(app, ["init", str(target), "--ai", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}"
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -248,6 +248,8 @@ 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"
@@ -304,6 +306,8 @@ 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"

View File

@@ -0,0 +1,11 @@
"""Tests for GooseIntegration."""
from .test_integration_base_yaml import YamlIntegrationTests
class TestGooseIntegration(YamlIntegrationTests):
KEY = "goose"
FOLDER = ".goose/"
COMMANDS_SUBDIR = "recipes"
REGISTRAR_DIR = ".goose/recipes"
CONTEXT_FILE = "AGENTS.md"

View File

@@ -50,16 +50,25 @@ class TestAgentConfigConsistency:
def test_devcontainer_kiro_installer_uses_pinned_checksum(self):
"""Devcontainer installer should always verify Kiro installer via pinned SHA256."""
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(encoding="utf-8")
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(
encoding="utf-8"
)
assert 'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"' in post_create_text
assert (
'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"'
in post_create_text
)
assert "sha256sum -c -" in post_create_text
assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text
def test_agent_context_scripts_use_kiro_cli(self):
"""Agent context scripts should advertise kiro-cli and not legacy q agent key."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "kiro-cli" in bash_text
assert "kiro-cli" in pwsh_text
@@ -89,8 +98,12 @@ class TestAgentConfigConsistency:
def test_agent_context_scripts_include_tabnine(self):
"""Agent context scripts should support tabnine agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "tabnine" in bash_text
assert "TABNINE_FILE" in bash_text
@@ -121,7 +134,9 @@ class TestAgentConfigConsistency:
def test_kimi_in_powershell_validate_set(self):
"""PowerShell update-agent-context script should include 'kimi' in ValidateSet."""
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
ps_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
assert validate_set_match is not None
@@ -155,8 +170,12 @@ class TestAgentConfigConsistency:
def test_trae_in_agent_context_scripts(self):
"""Agent context scripts should support trae agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "trae" in bash_text
assert "TRAE_FILE" in bash_text
@@ -165,7 +184,9 @@ class TestAgentConfigConsistency:
def test_trae_in_powershell_validate_set(self):
"""PowerShell update-agent-context script should include 'trae' in ValidateSet."""
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
ps_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
assert validate_set_match is not None
@@ -200,7 +221,9 @@ class TestAgentConfigConsistency:
def test_pi_in_powershell_validate_set(self):
"""PowerShell update-agent-context script should include 'pi' in ValidateSet."""
ps_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
ps_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text)
assert validate_set_match is not None
@@ -210,8 +233,12 @@ class TestAgentConfigConsistency:
def test_agent_context_scripts_include_pi(self):
"""Agent context scripts should support pi agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "pi" in bash_text
assert "Pi Coding Agent" in bash_text
@@ -242,8 +269,12 @@ class TestAgentConfigConsistency:
def test_iflow_in_agent_context_scripts(self):
"""Agent context scripts should support iflow agent type."""
bash_text = (REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh").read_text(encoding="utf-8")
pwsh_text = (REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1").read_text(encoding="utf-8")
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "iflow" in bash_text
assert "IFLOW_FILE" in bash_text
@@ -253,3 +284,37 @@ class TestAgentConfigConsistency:
def test_ai_help_includes_iflow(self):
"""CLI help text for --ai should include iflow."""
assert "iflow" in AI_ASSISTANT_HELP
# --- Goose consistency checks ---
def test_goose_in_agent_config(self):
"""AGENT_CONFIG should include goose with correct folder and commands_subdir."""
assert "goose" in AGENT_CONFIG
assert AGENT_CONFIG["goose"]["folder"] == ".goose/"
assert AGENT_CONFIG["goose"]["commands_subdir"] == "recipes"
assert AGENT_CONFIG["goose"]["requires_cli"] is True
def test_goose_in_extension_registrar(self):
"""Extension command registrar should include goose targeting .goose/recipes."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "goose" in cfg
assert cfg["goose"]["dir"] == ".goose/recipes"
assert cfg["goose"]["format"] == "yaml"
assert cfg["goose"]["args"] == "{{args}}"
def test_goose_in_agent_context_scripts(self):
"""Agent context scripts should support goose agent type."""
bash_text = (
REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh"
).read_text(encoding="utf-8")
pwsh_text = (
REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1"
).read_text(encoding="utf-8")
assert "goose" in bash_text
assert "goose" in pwsh_text
def test_ai_help_includes_goose(self):
"""CLI help text for --ai should include goose."""
assert "goose" in AI_ASSISTANT_HELP

View File

@@ -2995,6 +2995,122 @@ class TestExtensionAddCLI:
f"but was called with '{download_called_with[0]}'"
)
def test_add_bundled_extension_not_found_gives_clear_error(self, tmp_path):
"""extension add should give a clear error when a bundled extension is not found locally."""
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from specify_cli import app
runner = CliRunner()
# Create project structure
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
(project_dir / ".specify" / "extensions").mkdir(parents=True)
# Mock catalog that returns a bundled extension without download_url
mock_catalog = MagicMock()
mock_catalog.get_extension_info.return_value = {
"id": "git",
"name": "Git Branching Workflow",
"version": "1.0.0",
"description": "Git branching extension",
"bundled": True,
"_install_allowed": True,
}
mock_catalog.search.return_value = []
with patch("specify_cli.extensions.ExtensionCatalog", return_value=mock_catalog), \
patch("specify_cli._locate_bundled_extension", return_value=None), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", "git"],
catch_exceptions=True,
)
assert result.exit_code != 0
assert "bundled with spec-kit" in result.output
assert "reinstall" in result.output.lower()
class TestDownloadExtensionBundled:
"""Tests for download_extension handling of bundled extensions."""
def test_download_extension_raises_for_bundled(self, temp_dir):
"""download_extension should raise a clear error for bundled extensions without a URL."""
from unittest.mock import patch
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
bundled_ext_info = {
"name": "Git Branching Workflow",
"id": "git",
"version": "1.0.0",
"description": "Git workflow",
"bundled": True,
}
with patch.object(catalog, "get_extension_info", return_value=bundled_ext_info):
with pytest.raises(ExtensionError, match="bundled with spec-kit"):
catalog.download_extension("git")
def test_download_extension_allows_bundled_with_url(self, temp_dir):
"""download_extension should allow bundled extensions that have a download_url (newer version)."""
from unittest.mock import patch, MagicMock
import urllib.request
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
bundled_with_url = {
"name": "Git Branching Workflow",
"id": "git",
"version": "2.0.0",
"description": "Git workflow",
"bundled": True,
"download_url": "https://example.com/git-2.0.0.zip",
}
mock_response = MagicMock()
mock_response.read.return_value = b"fake zip data"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \
patch.object(urllib.request, "urlopen", return_value=mock_response):
result = catalog.download_extension("git")
assert result.name == "git-2.0.0.zip"
def test_download_extension_raises_no_url_for_non_bundled(self, temp_dir):
"""download_extension should raise 'no download URL' for non-bundled extensions without URL."""
from unittest.mock import patch
project_dir = temp_dir / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
catalog = ExtensionCatalog(project_dir)
non_bundled_ext_info = {
"name": "Some Extension",
"id": "some-ext",
"version": "1.0.0",
"description": "Test",
}
with patch.object(catalog, "get_extension_info", return_value=non_bundled_ext_info):
with pytest.raises(ExtensionError, match="has no download URL"):
catalog.download_extension("some-ext")
class TestExtensionUpdateCLI:
"""CLI integration tests for extension update command."""

View File

@@ -2865,3 +2865,182 @@ class TestPresetEnableDisable:
assert result.exit_code == 1
assert "corrupted state" in result.output.lower()
# ===== Lean Preset Tests =====
LEAN_PRESET_DIR = Path(__file__).parent.parent / "presets" / "lean"
LEAN_COMMAND_NAMES = [
"speckit.specify",
"speckit.plan",
"speckit.tasks",
"speckit.implement",
"speckit.constitution",
]
class TestLeanPreset:
"""Tests for the lean preset that ships with the repo."""
def test_lean_preset_exists(self):
"""Verify the lean preset directory and manifest exist."""
assert LEAN_PRESET_DIR.exists()
assert (LEAN_PRESET_DIR / "preset.yml").exists()
def test_lean_manifest_valid(self):
"""Verify the lean preset manifest is valid."""
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
assert manifest.id == "lean"
assert manifest.name == "Lean Workflow"
assert manifest.version == "1.0.0"
assert len(manifest.templates) == 5 # 5 commands
def test_lean_provides_core_workflow_commands(self):
"""Verify the lean preset provides overrides for core workflow commands."""
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
provided_names = {t["name"] for t in manifest.templates}
for name in LEAN_COMMAND_NAMES:
assert name in provided_names, f"Lean preset missing command: {name}"
def test_lean_command_files_exist(self):
"""Verify that all declared command files actually exist on disk."""
manifest = PresetManifest(LEAN_PRESET_DIR / "preset.yml")
for tmpl in manifest.templates:
tmpl_path = LEAN_PRESET_DIR / tmpl["file"]
assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}"
def test_lean_commands_have_no_scripts(self):
"""Verify lean commands have no scripts or agent_scripts in frontmatter."""
from specify_cli.agents import CommandRegistrar
for name in LEAN_COMMAND_NAMES:
cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md"
content = cmd_path.read_text()
frontmatter, _ = CommandRegistrar.parse_frontmatter(content)
assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter"
assert "agent_scripts" not in frontmatter, f"{name} should not have agent_scripts in frontmatter"
def test_lean_commands_have_no_hooks(self):
"""Verify lean commands do not contain extension hook boilerplate."""
for name in LEAN_COMMAND_NAMES:
cmd_path = LEAN_PRESET_DIR / "commands" / f"speckit.{name.split('.')[-1]}.md"
content = cmd_path.read_text()
assert "hooks." not in content, f"{name} should not reference extension hooks"
assert "extensions.yml" not in content, f"{name} should not reference extensions.yml"
def test_install_lean_preset(self, project_dir):
"""Test installing the lean preset from its directory."""
manager = PresetManager(project_dir)
manifest = manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0")
assert manifest.id == "lean"
assert manager.registry.is_installed("lean")
def test_lean_overrides_commands(self, project_dir):
"""Test that lean preset overrides are resolved correctly."""
manager = PresetManager(project_dir)
manager.install_from_directory(LEAN_PRESET_DIR, "0.6.0")
resolver = PresetResolver(project_dir)
for name in LEAN_COMMAND_NAMES:
result = resolver.resolve(name, template_type="command")
assert result is not None, f"Lean override for {name} not resolved"
# ===== Bundled Preset Locator Tests =====
class TestBundledPresetLocator:
"""Tests for _locate_bundled_preset discovery function."""
def test_locate_bundled_lean_preset(self):
"""_locate_bundled_preset finds the lean preset."""
from specify_cli import _locate_bundled_preset
path = _locate_bundled_preset("lean")
assert path is not None
assert (path / "preset.yml").is_file()
def test_locate_bundled_preset_not_found(self):
"""_locate_bundled_preset returns None for nonexistent preset."""
from specify_cli import _locate_bundled_preset
path = _locate_bundled_preset("nonexistent-preset")
assert path is None
def test_locate_bundled_preset_rejects_invalid_id(self):
"""_locate_bundled_preset rejects IDs with invalid characters."""
from specify_cli import _locate_bundled_preset
assert _locate_bundled_preset("../escape") is None
assert _locate_bundled_preset("UPPERCASE") is None
assert _locate_bundled_preset("has spaces") is None
def test_bundled_preset_add_via_cli(self, project_dir):
"""Test that 'specify preset add lean' installs the bundled preset."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.get_speckit_version", return_value="0.6.0"):
result = runner.invoke(app, ["preset", "add", "lean"])
assert result.exit_code == 0, result.output
assert "Lean Workflow" in result.output
assert "installed" in result.output.lower()
def test_bundled_preset_in_catalog(self):
"""Verify the lean preset is listed in catalog.json with bundled marker."""
catalog_path = Path(__file__).parent.parent / "presets" / "catalog.json"
catalog = json.loads(catalog_path.read_text())
assert "lean" in catalog["presets"]
assert catalog["presets"]["lean"]["bundled"] is True
assert "download_url" not in catalog["presets"]["lean"]
def test_bundled_preset_download_raises_error(self, project_dir):
"""download_pack raises PresetError for bundled presets without download_url."""
catalog = PresetCatalog(project_dir)
catalog_data = {
"test-bundled": {
"name": "Test Bundled",
"version": "1.0.0",
"bundled": True,
}
}
from unittest.mock import patch
with patch.object(catalog, "_get_merged_packs", return_value=catalog_data):
with pytest.raises(PresetError, match="bundled with spec-kit"):
catalog.download_pack("test-bundled")
def test_bundled_preset_missing_locally_cli_error(self, project_dir):
"""CLI shows clear error when bundled preset cannot be found locally."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
# Patch _locate_bundled_preset to return None (simulating missing files)
# and mock the catalog to return a bundled entry for "lean"
fake_pack_info = {
"id": "lean",
"name": "Lean Workflow",
"version": "1.0.0",
"bundled": True,
"_install_allowed": True,
}
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli._locate_bundled_preset", return_value=None), \
patch("specify_cli.presets.PresetCatalog") as MockCatalog:
MockCatalog.return_value.get_pack_info.return_value = fake_pack_info
result = runner.invoke(app, ["preset", "add", "lean"])
# Should fail with a helpful error explaining this is a bundled preset
# and suggesting how to recover.
assert result.exit_code == 1
output = strip_ansi(result.output).lower()
assert "bundled" in output, result.output
assert "reinstall" in output, result.output

View File

@@ -26,6 +26,13 @@ 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:
@@ -271,6 +278,30 @@ 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 ─────────────────────────────────────────
@@ -303,6 +334,67 @@ 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:
def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path):
"""get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup."""
(tmp_path / ".specify").mkdir()
(tmp_path / "specs" / "001-target-spec").mkdir(parents=True)
cmd = (
f'cd "{tmp_path}" && export SPECIFY_FEATURE="feat/001-other" && '
f'source "{COMMON_SH}" && eval "$(get_feature_paths)" && printf "%s" "$FEATURE_DIR"'
)
result = subprocess.run(
["bash", "-c", cmd],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == str(tmp_path / "specs" / "001-target-spec")
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
"""PowerShell Get-FeaturePathsEnv: same prefix stripping as bash."""
common_ps = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
spec_dir = git_repo / "specs" / "001-ps-prefix-spec"
spec_dir.mkdir(parents=True)
ps_cmd = f'. "{common_ps}"; $r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"'
result = subprocess.run(
["pwsh", "-NoProfile", "-Command", ps_cmd],
cwd=git_repo,
capture_output=True,
text=True,
env={**os.environ, "SPECIFY_FEATURE": "feat/001-other"},
)
assert result.returncode == 0, result.stderr
for line in result.stdout.splitlines():
if line.startswith("FEATURE_DIR="):
val = line.split("=", 1)[1].strip()
assert val == str(spec_dir)
break
else:
pytest.fail("FEATURE_DIR not found in PowerShell output")
# ── get_current_branch Tests ─────────────────────────────────────────────────
@@ -791,15 +883,6 @@ 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"

1803
tests/test_workflows.py Normal file

File diff suppressed because it is too large Load Diff

211
workflows/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,211 @@
# Workflow System Architecture
This document describes the internal architecture of the workflow engine — how definitions are parsed, steps are dispatched, state is persisted, and catalogs are resolved.
For usage instructions, see [README.md](README.md).
## Execution Model
When `specify workflow run` is invoked, the engine loads a YAML definition, resolves inputs, and dispatches steps sequentially through the step registry:
```mermaid
flowchart TD
A["specify workflow run my-workflow"] --> B["WorkflowEngine.load_workflow()"]
B --> C["WorkflowDefinition.from_yaml()"]
C --> D["_resolve_inputs()"]
D --> E["validate_workflow()"]
E --> F["RunState.create()"]
F --> G["_execute_steps()"]
G --> H{Step type?}
H -- command --> I["CommandStep.execute()"]
H -- shell --> J["ShellStep.execute()"]
H -- gate --> K["GateStep.execute()"]
H -- "if" --> L["IfThenStep.execute()"]
H -- switch --> M["SwitchStep.execute()"]
H -- "while/do-while" --> N["Loop steps"]
H -- "fan-out/fan-in" --> O["Fan-out/fan-in"]
I --> P{Result status?}
J --> P
K --> P
L --> P
M --> P
N --> P
O --> P
P -- COMPLETED --> Q{Has next_steps?}
P -- PAUSED --> R["Save state → exit"]
P -- FAILED --> S["Log error → exit"]
Q -- Yes --> G
Q -- No --> T{More steps?}
T -- Yes --> G
T -- No --> U["Status = COMPLETED"]
style R fill:#ff9800,color:#fff
style S fill:#f44336,color:#fff
style U fill:#4caf50,color:#fff
```
### Sequential Execution
Steps execute sequentially. Each step receives a `StepContext` containing resolved inputs, accumulated step results, and workflow-level defaults. After execution, the step's output is stored in `context.steps[step_id]` and made available to subsequent steps via expressions like `{{ steps.specify.output.file }}`.
### Nested Steps (Control Flow)
Steps like `if`, `switch`, `while`, and `do-while` return `next_steps` — inline step definitions that the engine executes recursively via `_execute_steps()`. Nested steps share the same `StepContext` and `RunState`, so their outputs are visible to later top-level steps.
### State Persistence and Resume
The engine saves `RunState` to disk after each step, enabling resume from the exact point of interruption:
```mermaid
flowchart LR
A["CREATED"] --> B["RUNNING"]
B --> C["COMPLETED"]
B --> D["PAUSED"]
B --> E["FAILED"]
B --> F["ABORTED"]
D -- "resume()" --> B
E -- "resume()" --> B
```
When a `gate` step pauses execution, the engine persists `current_step_index` and all accumulated `step_results`. On `specify workflow resume <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
```

285
workflows/PUBLISHING.md Normal file
View File

@@ -0,0 +1,285 @@
# Workflow Publishing Guide
This guide explains how to publish your workflow to the Spec Kit workflow catalog, making it discoverable by `specify workflow search`.
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Prepare Your Workflow](#prepare-your-workflow)
3. [Submit to Catalog](#submit-to-catalog)
4. [Verification Process](#verification-process)
5. [Release Workflow](#release-workflow)
6. [Best Practices](#best-practices)
---
## Prerequisites
Before publishing a workflow, ensure you have:
1. **Valid Workflow**: A working `workflow.yml` that passes `specify workflow run` validation
2. **Git Repository**: Workflow hosted on GitHub (or other public git hosting)
3. **Documentation**: README.md with description, inputs, and step graph
4. **License**: Open source license file (MIT, Apache 2.0, etc.)
5. **Versioning**: Semantic versioning in the `workflow.version` field
6. **Testing**: Workflow tested on real projects
---
## Prepare Your Workflow
### 1. Workflow Structure
Host your workflow in a repository with this structure:
```text
your-workflow/
├── workflow.yml # Required: Workflow definition
├── README.md # Required: Documentation
├── LICENSE # Required: License file
└── CHANGELOG.md # Recommended: Version history
```
### 2. workflow.yml Validation
Verify your definition is valid:
```yaml
schema_version: "1.0"
workflow:
id: "your-workflow" # Unique lowercase-hyphenated ID
name: "Your Workflow Name" # Human-readable name
version: "1.0.0" # Semantic version
author: "Your Name or Organization"
description: "Brief description (one sentence)"
integration: claude # Default integration (optional)
model: "claude-sonnet-4-20250514" # Default model (optional)
requires:
speckit_version: ">=0.6.1"
integrations:
any: ["claude", "gemini"] # At least one required
inputs:
feature_name:
type: string
required: true
prompt: "Feature name"
scope:
type: string
default: "full"
enum: ["full", "backend-only", "frontend-only"]
steps:
- id: specify
command: speckit.specify
input:
args: "{{ inputs.feature_name }}"
- id: review
type: gate
message: "Review the output."
options: [approve, reject]
on_reject: abort
```
**Validation Checklist**:
-`id` is lowercase alphanumeric with hyphens (single-character IDs are allowed)
-`version` follows semantic versioning (X.Y.Z)
-`description` is concise
- ✅ All step IDs are unique
- ✅ Step types are valid: `command`, `prompt`, `shell`, `gate`, `if`, `switch`, `while`, `do-while`, `fan-out`, `fan-in`
- ✅ Required fields present per step type (e.g., `condition` for `if`, `expression` for `switch`)
- ✅ Input types are valid: `string`, `number`, `boolean`
- ✅ Step IDs do not contain `:` (reserved for engine-generated nested IDs like `parentId:childId`)
### 3. Test Locally
```bash
# Run with required inputs
specify workflow run ./workflow.yml --input feature_name="user-auth"
# Check validation
specify workflow info ./workflow.yml
# Resume after a gate pause
specify workflow resume <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

339
workflows/README.md Normal file
View File

@@ -0,0 +1,339 @@
# Workflows
Workflows are multi-step, resumable automation pipelines defined in YAML. They orchestrate Spec Kit commands across integrations, evaluate control flow, and pause at human review gates — enabling end-to-end Spec-Driven Development cycles without manual step-by-step invocation.
## How It Works
A workflow definition declares a sequence of steps. The engine executes them in order, dispatching commands to AI integrations, running shell commands, evaluating conditions for branching, and pausing at gates for human review. State is persisted after each step, so workflows can be resumed after interruption.
```yaml
steps:
- id: specify
command: speckit.specify
input:
args: "{{ inputs.feature_name }}"
- id: review
type: gate
message: "Review the spec before planning."
options: [approve, reject]
on_reject: abort
- id: plan
command: speckit.plan
```
For detailed architecture and internals, see [ARCHITECTURE.md](ARCHITECTURE.md).
## Quick Start
```bash
# Search available workflows
specify workflow search
# Install the built-in SDD workflow
specify workflow add speckit
# Or run directly from a local YAML file
specify workflow run ./workflow.yml --input feature_name="user-auth"
# Run an installed workflow with inputs
specify workflow run speckit --input feature_name="user-auth"
# Check run status
specify workflow status
# Resume after a gate pause
specify workflow resume <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
```

View File

@@ -0,0 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-10T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json",
"workflows": {}
}

16
workflows/catalog.json Normal file
View File

@@ -0,0 +1,16 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-13T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.json",
"workflows": {
"speckit": {
"id": "speckit",
"name": "Full SDD Cycle",
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
"author": "GitHub",
"version": "1.0.0",
"url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/speckit/workflow.yml",
"tags": ["sdd", "full-cycle"]
}
}
}

View File

@@ -0,0 +1,63 @@
schema_version: "1.0"
workflow:
id: "speckit"
name: "Full SDD Cycle"
version: "1.0.0"
author: "GitHub"
description: "Runs specify → plan → tasks → implement with review gates"
requires:
speckit_version: ">=0.6.1"
integrations:
any: ["copilot", "claude", "gemini"]
inputs:
feature_name:
type: string
required: true
prompt: "Feature name"
integration:
type: string
default: "copilot"
prompt: "Integration to use (e.g. claude, copilot, gemini)"
scope:
type: string
default: "full"
enum: ["full", "backend-only", "frontend-only"]
steps:
- id: specify
command: speckit.specify
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.feature_name }}"
- id: review-spec
type: gate
message: "Review the generated spec before planning."
options: [approve, reject]
on_reject: abort
- id: plan
command: speckit.plan
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.feature_name }}"
- id: review-plan
type: gate
message: "Review the plan before generating tasks."
options: [approve, reject]
on_reject: abort
- id: tasks
command: speckit.tasks
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.feature_name }}"
- id: implement
command: speckit.implement
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.feature_name }}"