Compare commits

..

54 Commits

Author SHA1 Message Date
github-actions[bot]
272eabdea4 chore: bump version to 0.11.9 2026-06-26 17:22:47 +00:00
Dyan Galih
465d29910e Docs: add cline and zcode to multi-install-safe table (#3180)
Fixes #3175
2026-06-26 12:21:38 -05:00
Dyan Galih
916e29b27b Docs: document missing flags --force and --refresh-shared-infra (#3179)
* Docs: document missing flags --force and --refresh-shared-infra

Fixes #3177

* Address review: Reorder flags to match CLI help output
2026-06-26 12:20:51 -05:00
Manfred Riem
c49966da4d fix(claude): stop forking /speckit-analyze to prevent long-session freezes (#3188)
PR #2511 added `context: fork` + `agent: general-purpose` to the generated
speckit-analyze SKILL.md on the assumption that its heavy reads collapse to a
short summary. In practice /speckit-analyze returns a 300-500 line report that
is injected back into the main conversation. In long sessions each subsequent
fork inherits that growing context, compounding overhead until the chat
freezes (#3185).

Empty FORK_CONTEXT_COMMANDS so no command opts into context: fork, restoring
direct in-session execution for analyze. The injection mechanism is retained
so a command can be re-enabled once it genuinely returns a compact result.

Fixes #3185

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 12:11:54 -05:00
Amirreza Alibeigi
49cc05384a fix: derive plan path from feature.json in update-agent-context (#3069)
* fix: derive plan path from feature.json in update-agent-context

When `plan_path` is omitted, prefer `.specify/feature.json`
(written by /speckit-specify) over the mtime heuristic. The
old approach picked the most recently modified `specs/*/plan.md`,
which could inject an unrelated plan into CLAUDE.md if another
spec's plan was touched after the active feature directory was
created but before its own plan.md existed.

Bash: handle both relative and absolute feature_directory values,
normalizing absolute paths back to project-relative for the
context file. Fall back to mtime only when feature.json is absent
or the derived plan.md does not yet exist.

PowerShell: same logic, PS 5.1-compatible (nested Join-Path,
IsPathRooted guard to avoid Unix Join-Path mis-joining absolute
ChildPaths, manual prefix-strip instead of GetRelativePath).

Fixes #3067

* fix: address Copilot review feedback on update-agent-context

- bash: add explicit encoding="utf-8" to feature.json open() call
- powershell: replace GetRelativePath (.NET 5+ only) with manual
  prefix-strip in mtime fallback for PS 5.1 compatibility
- tests: add coverage for absolute feature_directory values
  (under and outside PROJECT_ROOT)

* Potential fix for pull request finding

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

* test: replace time.sleep with os.utime and strengthen PS normalization assertion

* Apply suggestions from code review

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

* Potential fix for pull request finding

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

* fix: normalize trailing slash and guard non-string feature_directory in PS script

* Fix: use .resolve().as_posix().

Valid. The PS tests run on Windows where str(tmp_path) uses backslashes, but the PS script normalizes output to forward slashes. Assertions like assert str(tmp_path) not in ctx become false negatives on Windows CI.

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

* fix: use context manager for feature.json open() in bash heredoc

* test: add PS coverage for absolute feature_directory outside project root

* fix: guard null feature_directory, re-check empty after trailing-slash strip, fix blank line

* test: add stale plan to absolute-path tests so feature.json preference is actually exercised

* test: convert absolute paths to MSYS2 style for Git-for-Windows bash compatibility

* fix: revert PS test to native path, fix bash outside-root assertion for Git bash

* fix: use _to_bash_path in not-in assertion for Git bash Windows compat

* fix: add ConvertFrom-Json fallback in PS script, write test config as JSON

* fix: use OS-appropriate StringComparison in PS prefix-strip (matches common.ps1)

* fix: emit project-relative POSIX path from mtime fallback; use upstream test helpers

* fix: write config as JSON directly, drop _install_agent_context_config

* fix: normalize backslashes to forward slashes in feature_directory before path ops

* fix: treat drive-qualified paths (C:/...) as absolute after backslash normalization

* fix: resolve symlinks when computing relative plan path; use UTF8 encoding in PS ConvertFrom-Yaml path

* fix: use bash-side path for outside-root case to avoid WindowsPath backslashes

* fix: use .as_posix() instead of PurePosixPath() to avoid backslashes on native Windows Python

* fix: resolve ./.. segments in PS feature_directory via GetFullPath before relativizing

* fix: replace $IsWindows guard with OSVersion.Platform check for PS 5.1 StrictMode compat

* fix: guard empty relDir to avoid leading slash in PlanPath when feature_directory is project root

* fix: remove unused PurePosixPath import; fix stale PS comment after ConvertFrom-Json fallback was added

* fix: use cand.as_posix() for outside-root path instead of raw bash-side argv

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-26 12:07:44 -05:00
Alfredo Perez
5f9791b524 fix(catalog): companion → README docs, version-pinned download URL, v0.11.0, refreshed tags (#2954)
* fix(catalog): point companion documentation at README.md so it renders

The companion entry's documentation URL pointed at a directory
(speckit-extension/docs/), which the community site can't fetch as
markdown — its extension page renders an empty README (readmeContent:
null). Every other catalog entry points documentation at a specific
README.md (or .md file). Point companion at its extension README so the
page renders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(catalog): companion → stable companion-latest download_url, v0.4.1, sharper tags

- download_url now points at the rolling companion-latest asset so by-name install always serves the newest build (no per-release catalog PR)
- version 0.3.0 → 0.4.1
- tags: drop redundant 'companion'/'progress'/'lifecycle', add spec-driven-development, spec-kit, turbo, capture

* fix(catalog): companion tags → capability-first (vscode, progress, status, resume, configurable, extensible)

Tags now name what Companion adds over stock spec-kit, in browse-able terms — dropped catalog-noise (spec-kit, spec-driven-development) and insider jargon (turbo, capture).

* fix(catalog): pin companion to speckit-ext-v0.8.0 asset; sync entry

Pin download_url to the version-matched release asset (every other catalog
entry pins to a tag; the floating companion-latest URL made installs
non-reproducible). Bring the entry up to v0.8.0: version 0.4.1 -> 0.8.0,
commands 10 -> 12, speckit_version floor >=0.9.5.dev0, and drop the removed
"turbo pipeline profile" from the description in favor of the hooks/recipes
customization that shipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(catalog): bump companion to v0.11.0 (latest released asset, 13 commands)

* fix(catalog): companion speckit_version floor >=0.9.5 (drop pointless .dev0)

* fix(catalog): align companion updated_at with catalog root (2026-06-24)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:59:32 -05:00
dependabot[bot]
1d989b90d5 chore(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#3173)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](a309ff8b42...ece7cb06ca)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-26 09:05:38 -05:00
github-actions[bot]
e7ec7c190f Update SicarioSpec Core preset to v0.5.1 (#3165)
Update sicario-core preset submitted by @SiCar10mw:
- presets/catalog.community.json (version, download_url, description, tags)
- docs/community/presets.md community presets table

Closes #3164

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 14:10:23 -05:00
Si Zengyu
1add20341d fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157)
* feat(auth): add github_provider_hosts() to enumerate GHES hosts from auth.json

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* fix(extensions): resolve GHES release assets via /api/v3

Generalizes resolve_github_release_asset_api_url to GitHub Enterprise
Server hosts (gated by auth.json github hosts), fixing private GHES
extension/preset downloads. github/spec-kit#3147

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* fix(extensions,presets): pass auth.json github hosts into release resolver

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* docs(auth): document GHES private catalog + release-asset auth

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* fix(presets,workflows): pass auth.json github hosts into remaining release resolvers

Wires preset add --from and workflow add through github_provider_hosts()
so private GHES release assets resolve via /api/v3 there too. github/spec-kit#3147

Assisted-by: Claude Code (model: claude-sonnet-4-6, autonomous)

* test(presets): use module-level io.BytesIO in GHES preset test

Addresses Copilot review on PR #3157: drop unnecessary __import__("io")
in test_preset_add_from_ghes_release_url_resolves_via_api_v3 since io is
already imported at module level.

* fix(github-http): pass through GHES asset API URLs by path shape

Addresses Copilot review on PR #3157. A direct GHES /api/v3 release asset
URL was only returned as already-resolved when its host was in the
allowlist; otherwise the resolver returned None and the caller downloaded
the same URL without 'Accept: application/octet-stream', fetching JSON
metadata instead of the binary.

Gate the passthrough on path shape alone, mirroring the github.com case.
This is safe: passthrough returns the input URL unchanged and the caller
fetches it either way, so no new request to an arbitrary host is induced;
the token stays independently gated by auth.json in open_url. The
allowlist remains the anti-SSRF gate on the tag-lookup resolving path.

Add test_passthrough_for_unlisted_ghes_api_asset_url.
2026-06-25 10:44:30 -05:00
WOLIKIMCHENG
7624dd6582 Update preset composition strategy reference (#3143)
* docs: update preset composition strategy reference

* docs: clarify preset command composition timing

* docs: clarify preset command reconciliation timing

* docs: clarify preset file resolution behavior

* docs: clarify preset command reconciliation wording

---------

Co-authored-by: root <kinsonnee@gmail.com>
2026-06-25 10:13:14 -05:00
Ali jawwad
9fe1c4cc5c fix(scripts): keep PowerShell branch-name acronym match case-sensitive (parity with bash) (#3129)
* fix(scripts): keep PowerShell branch-name acronym match case-sensitive

Get-BranchName keeps a sub-3-character word only when it appears as an
UPPERCASE acronym in the description. The bash twin checks this
case-sensitively (grep "\b${word^^}\b" / grep -qw -- "${word^^}"), but the
PowerShell twin used -match, which is case-INSENSITIVE, so it kept EVERY
short word regardless of case -- contradicting its own comment and diverging
from bash. The same description then produced different spec-directory and
branch names on Windows/PowerShell vs macOS/Linux (e.g. "Add go support" ->
001-go-support instead of 001-support), desyncing specs/, feature.json, and
git branches across a mixed-OS team.

Use the case-sensitive -cmatch so a short word is kept only for a genuine
uppercase acronym, matching bash. Applied to both the core
scripts/powershell/create-new-feature.ps1 and the git extension's
create-new-feature-branch.ps1.

Add bash + PowerShell regression tests (core and git-extension) asserting a
lowercase short word is dropped while an uppercase acronym is kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: fix article grammar in branch-name docstrings

Address review: 'an UPPERCASE acronym' -> 'an acronym in UPPERCASE' across the four branch-name case-sensitivity test docstrings (the indefinite article reads cleanly before 'acronym'). Docstring-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:56:59 -05:00
Quratulain-bilal
bb37b180d6 fix(extensions): tell agent to run mandatory hooks, not just emit the directive (#2901)
In agent-direct invocations nothing watches agent output for the
EXECUTE_COMMAND: directive, so a mandatory hook that is only emitted
never runs and the failure is silent (#2730). Add one line after each
mandatory-hook block instructing the agent to actually invoke the hook
and wait for it before continuing.

The instruction tells the agent to run the hook the way it would run
the command itself in the current agent/session, and notes the
invocation may differ from the literal {command} id shown in the block
(e.g. skills-mode agents run it as /skill:speckit-... or $speckit-...),
so it stays correct outside the default slash-command form.

Fixes #2730
2026-06-25 07:48:23 -05:00
siC@r10-mw
77e6f43b82 Point sicario-core docs to preset README (#3120)
* Point sicario-core docs to preset README

* Update sicario-core catalog timestamps

Assisted-by: OpenAI Codex (GPT-5, autonomous)
2026-06-25 07:25:41 -05:00
Manfred Riem
d65f6bd335 chore: release 0.11.8, begin 0.11.9.dev0 development (#3156)
* chore: bump version to 0.11.8

* chore: begin 0.11.9.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-24 17:42:49 -05:00
Rafael Sales
05cf078ea4 docs: add SpecKit Assistant npm package to Community Friends (#3142)
* docs: add SpecKit Assistant npm package to Community Friends

Adds SpecKit Assistant (https://www.npmjs.com/package/speckit-assistant)
to the Community Friends list. It is a visual interface for the specify
CLI that orchestrates Spec-Driven Development (SDD) — connecting local
specification, planning, and task checklists with AI agents (Claude,
Gemini, Copilot). No installation required; run it via npx speckit-assistant.

As the author of both the VS Code Spec Kit Assistant extension and the
SpecKit Assistant npm package, I maintain these community tools that
provide a visual interface on top of the specify CLI.

* Potential fix for pull request finding

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

* docs: clarify SpecKit Assistant requires no global installation

Address Copilot review: 'No installation required' was misleading for an
npx-run package since npx still downloads it. Clarify that no global
installation is required.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-24 17:37:28 -05:00
Manfred Riem
96039d36d2 Require preset-usage README with Spec Kit CLI syntax in preset submissions (#3104)
* Require preset-usage README with Spec Kit CLI syntax in submissions

Tighten the community preset submission workflow so it validates the
README referenced by the documentation field rather than merely checking
for a root README. The workflow now fails submissions whose linked README
lacks a valid 'specify preset add ...' command and flags monorepo
submissions that point documentation at a generic root README.

- Add a required Documentation URL field to the preset issue template
- Add validation step 2d (documentation README + CLI-syntax check) to
  .github/workflows/add-community-preset.md and recompile the lock file
- Document the stricter usage-README requirement and reviewer content
  check in presets/PUBLISHING.md

Closes #3103

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Align preset README docs with workflow's actual enforcement

Address PR review feedback on #3104:
- PUBLISHING.md: clarify that only README resolution + a valid
  'specify preset add ...' command are mechanically enforced; the
  preset-scoped-README and minimum-structure items are reviewer
  expectations, not automated checks.
- PUBLISHING.md: state that a missing 'specify preset add ...' command
  is a hard validation failure (check 2d), not just 'flagged for changes'.
- preset_submission.yml: require 'specify preset add ...' (not the looser
  'specify preset ...') to match the workflow validation.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Tighten preset README validation and docs per PR review

Address PR review feedback on #3104:
- Workflow Step 2c: drop the generic repo-root README.md check so the
  README requirement is enforced exactly once, in Step 2d, against the
  file the documentation field points to (avoids monorepo false-positive).
- Workflow Step 2d: restrict the documentation URL to GitHub-hosted
  README URLs (github.com/.../blob/... or raw.githubusercontent.com/...)
  before fetching user-provided input.
- PUBLISHING.md: add the required 'id' field to the example catalog entry.
- preset_submission.yml: fix the Documentation URL placeholder to match
  the recommended monorepo presets/<id>/README.md pattern.
- Recompile add-community-preset.lock.yml (body hash only).

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Refine preset README validation rules per PR review

Address PR review feedback on #3104:
- Workflow Step 2d: broaden the documentation URL allowlist to also
  accept github.com/.../raw/... URLs; strip any fragment/query before
  fetching so the target is deterministic; clarify that a
  'specify preset add --from <url>' command only counts when its URL
  matches the submitted Download URL (a different --from URL does not
  satisfy the requirement, though other accepted forms still can).
- PUBLISHING.md: show both accepted download URL shapes (tag archive and
  release asset) in the README install example instead of implying only
  the releases/download form.
- preset_submission.yml: remove the ambiguous generic 'README.md with
  description and usage instructions' checkbox; the linked-README
  requirement is the single source of truth.
- Recompile add-community-preset.lock.yml (body hash only).

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Clarify install-command requirement wording per PR review

Address PR review feedback on #3104: the previous 'matching the download
URL' wording overstated the requirement. Only the 'specify preset add
--from <url>' form needs an exact download-URL match; other accepted
forms ('specify preset add <id>' / '--dev <path>') don't reference the
download URL at all.

- preset_submission.yml: reword the Documentation URL description and the
  Submission Requirements checkbox to reflect what's enforced vs preferred.
- PUBLISHING.md: clarify the reviewer note so the exact-match rule is
  scoped to the --from form.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Require README.md target and fix release-ZIP wording per PR review

Address PR review feedback on #3104:
- Workflow Step 2d: add an explicit check that the documentation URL path
  ends with README.md (case-insensitive) after stripping fragment/query,
  so a non-README markdown file is rejected before fetching.
- PUBLISHING.md: reword the release-ZIP note, which conflicted with the
  earlier preset structure guidance. The real requirement is that the
  README is reachable at the documentation URL before download; it's fine
  for the same file to also ship inside the release ZIP.
- Recompile add-community-preset.lock.yml (body hash only).

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Use stable unnumbered anchor for Usage README Requirements

Address PR review feedback on #3104: drop the '6.' prefix from the
'Usage README Requirements' heading so its GitHub anchor isn't tied to a
section number (brittle under renumbering, and avoids confusion with the
top-level 'Best Practices' TOC item). Update the Prerequisites cross-link
to the new #usage-readme-requirements anchor.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Align README requirement wording with enforced checks per PR review

Address PR review feedback on #3104:
- PUBLISHING.md: the 'mechanically enforces' summary now lists all Step 2d
  checks (GitHub-hosted URL, path ends with README.md, resolves, contains
  a valid 'specify preset add ...' command), instead of only two.
- PUBLISHING.md: reword the PR checklist item so a usage README + install
  command is the requirement, with preset-scoped README recommended for
  monorepos (matches the workflow's flag-not-fail behavior).
- preset_submission.yml: include the full 'specify preset add' prefix on
  the --dev and --from forms in the field description and checklist so
  submitters copy the exact syntax.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix grammar in Usage README Requirements intro

Address PR review feedback on #3104: remove the incorrect colon after
'the linked README' so the sentence reads naturally.

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Avoid lossy raw URL rewrite for slash-containing refs per PR review

Address PR review feedback on #3104: rewriting documentation URLs into the
raw.githubusercontent.com/<owner>/<repo>/<ref>/<path> form can't reliably
represent refs that contain slashes (e.g. a feature/foo branch). Step 2d
now fetches github.com blob URLs by swapping only /blob/ -> /raw/, and
fetches github.com/.../raw/... and raw.githubusercontent.com/... URLs
as-is, instead of reconstructing the raw host form.

Recompile add-community-preset.lock.yml (body hash only).

Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 17:06:51 -05:00
github-actions[bot]
d6cddd4127 [extension] Update Jira Integration (Sync Engine) extension to v0.4.0 (#3152)
* Update Jira Integration (Sync Engine) extension to v0.4.0

Update jira-sync extension submitted by @ashbrener:
- extensions/catalog.community.json (version, download_url, changelog, provides.commands, tags, requires.tools, updated_at)
- docs/community/extensions.md community extensions table (no change needed, row already current)

Closes #3149

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix review feedback: revert unrelated formatting, add bash version constraint, fix field ordering for jira-sync

- Revert unrelated em-dash/arrow encoding and tools array reformatting changes
  across the catalog (only jira-sync changes remain)
- Add version: \">=4.4\" to bash in jira-sync requires.tools
- Move category and effect fields to after license and before requires
  to match field ordering of neighboring entries

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-24 15:44:29 -05:00
github-actions[bot]
0cde6be41b Add Spec Roadmap extension to community catalog (#3153)
Add roadmap extension submitted by @srobroek to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3150

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 15:32:04 -05:00
meymchen
dc840f07d0 feat(integration): update Kimi integration for Kimi Code CLI (#2979)
* feat(integration): update Kimi integration for Kimi Code CLI

Update the Kimi integration to target the new Kimi Code CLI
(MoonshotAI/kimi-code) layout:

- Change skills directory from .kimi/skills/ to .kimi-code/skills/
- Change context file from KIMI.md to AGENTS.md
- Extend --migrate-legacy to move old .kimi/skills/ installs and
  migrate KIMI.md user content to AGENTS.md
- Clean up leftover legacy .kimi/skills/ directories on teardown
- Update devcontainer installer to @moonshot-ai/kimi-code
- Update docs and tests

Relates to #1532

* fix(integration): align Kimi dispatch and harden legacy migration

- Override build_command_invocation to emit /skill:speckit-<stem>
  so dispatched commands match Kimi Code CLI's native slash syntax.
- Skip symlinked .kimi/skills directories during legacy migration
  and teardown to avoid operating on files outside the project.
- Remove kimi from the multi-install-safe integrations table.
- Add tests for command invocation and symlink safety.

* fix(integration): resolve custom context markers in Kimi legacy migration

Use IntegrationBase._resolve_context_markers() when migrating legacy
KIMI.md content so that projects with customized context_markers in
.specify/extensions/agent-context/agent-context-config.yml have the
managed section stripped with the correct markers instead of the
hard-coded defaults.

Adds a test verifying custom markers are respected during
--migrate-legacy.

* fix(integration): harden Kimi legacy migration against symlinked paths

* fix(kimi): guard symlinked SKILL.md during migration and teardown

* docs(kimi): mention KIMI.md→AGENTS.md migration in --migrate-legacy help

The --migrate-legacy help text listed only the skills directory move and
dotted→hyphenated renaming, but the flag also migrates KIMI.md user content
into AGENTS.md. Align the help with the actual behavior, docs, and tests.

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

* fix(kimi): validate legacy migration destination; clarify docstrings

Address Copilot review feedback on PR #2979:

- setup(): gate skills migration on _is_safe_legacy_dir(new_skills_dir)
  as well as the source. base setup() already rejects a destination that
  escapes the project root, but an in-tree symlinked .kimi-code/skills
  (e.g. -> .) could still misdirect the move; this gives the destination
  the same symlink-component protection as the source.
- _migrate_legacy_kimi_dotted_skills: rewrite docstring as a compatibility
  shim describing same-path delegation to _migrate_legacy_kimi_skills_dir.
- test_presets: clarify that the dotted-skill test exercises legacy naming
  under the current .kimi-code/ base, not the legacy .kimi/ location.

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

* fix(kimi): harden legacy KIMI.md→AGENTS.md context migration

- Skip context-file migration when the agent-context extension is
  disabled, matching upsert/remove_context_section opt-out behavior so
  an opted-out project's KIMI.md/AGENTS.md are left untouched.
- Safely skip (instead of raising) on filesystem edge cases: unreadable
  or non-UTF-8 KIMI.md, and AGENTS.md existing as a non-file/unwritable.
- Refuse to migrate a corrupted managed section (single marker, or end
  before start) so a partial managed block is never copied into
  AGENTS.md; KIMI.md is preserved for manual repair.

Add regression tests for all three cases.

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

* Potential fix for pull request finding

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

* Fix for pull request finding

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

* Approve fix for pull request finding

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

* chore(kimi): revert CHANGELOG.md edit (auto-generated)

The CHANGELOG is generated from merged PR titles, so a hand-written entry
is redundant; it was also placed under the already-released 0.10.2 section,
which would make those release notes historically inaccurate. Revert to
match main per maintainer feedback.

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

* test(kimi): skip symlink-safety tests when symlinks are unavailable

The Kimi legacy-migration safety tests create symlinks to assert that
migration/teardown never follow them out of the project. Symlink creation
fails on Windows without the create-symlink privilege and in some restricted
CI sandboxes, so these tests errored during setup instead of skipping.

Wrap every symlink_to() call in a shared _symlink_or_skip() helper that
pytest.skip()s on OSError/NotImplementedError, matching the guard pattern
already used by one of these tests. Verified on Windows: the 6 symlink tests
now skip cleanly (51 passed, 6 skipped) instead of erroring.

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

* fix(kimi): reject symlinked skills destination before install

Add a destination symlink pre-check in KimiIntegration.setup() before
super().setup() writes any SKILL.md. The base class only rejects a
destination that escapes project_root after resolve(), so an in-tree
symlinked .kimi-code/.kimi-code/skills (e.g. `-> .`) would still
misdirect writes into an unintended in-tree location (./skills/).

Extract the symlink-component walk into a shared _has_symlinked_component()
helper and reuse it from _is_safe_legacy_dir(). Add a regression test.

Also clarify that --migrate-legacy only migrates KIMI.md -> AGENTS.md when
the agent-context extension is enabled, in the CLI help text and the
integration docs.

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

* Refactor formatting and simplify logic in Kimi integration

* fix(kimi): reject symlinked target dir during legacy skills migration

When the migration destination already exists, guard against a symlinked
(or non-directory) target_dir before comparing SKILL.md bytes, so the
comparison never follows a link outside the project root. Also skip a
missing/non-file target SKILL.md explicitly.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-24 15:22:08 -05:00
github-actions[bot]
e12beda5f9 [extension] Add Golden Demo extension to community catalog (#3151)
* Add Golden Demo extension to community catalog

Add golden-demo extension submitted by @jasstt to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3127

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove empty changelog field from golden-demo catalog entry

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-24 15:17:04 -05:00
Ali jawwad
5404f7ee1c docs: run /speckit.checklist after /speckit.plan in quickstart (#3108)
* docs: run /speckit.checklist after /speckit.plan in quickstart

The quickstart workflow showed /speckit.checklist before /speckit.plan,
contradicting the CLI next-steps text (commands/init.py), which lists the
checklist as running after the plan. Per the maintainer on #2816 — "the
docs were actually wrong here ... checklists are meant for after plan" —
align the docs to the CLI: move /speckit.checklist after /speckit.plan in
the workflow diagram, the prose, and both walkthrough step sequences.

Docs-only; no behavior change.

Closes #2606

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: reword checklist as generating quality checklists, not validating directly

Address review: /speckit.checklist generates quality checklists (which then validate the requirements) rather than validating directly, matching the CLI/README phrasing. Preserves the after-plan ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: align checklist wording with CLI next-steps phrasing

Address review: state the checklist's purpose (validate requirements completeness, clarity, and consistency) and anchor it to /speckit.plan as the CLI does, use the plural 'quality checklists', and reword the Taskify step so the spec is validated using the generated checklists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:16:36 -05:00
Ali jawwad
fdaaf18371 fix(workflows): preserve commas inside quoted list-literal elements (#3134)
* fix(workflows): preserve commas inside quoted list-literal elements

The simple-expression evaluator parsed a list literal with a naive
`inner.split(",")`, which splits on commas inside quoted strings (and
nested brackets). So `{{ ["a, b", "c"] }}` evaluated to three items
(`["a", "b", "c"]`) instead of two, silently corrupting `fan-out` `items:`
and any list expression that contains a comma inside a quoted element.

Split list-literal elements on top-level commas only, ignoring commas
inside quotes or nested brackets, via a small `_split_top_level_commas`
helper. Plain and empty lists are unchanged.

Add tests covering quoted commas, nested lists, and the existing
plain/empty cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(workflows): cover single-quoted and nested list literals

Address review: extend the list-literal regression test to assert single-quoted elements with commas and nested lists parse correctly, alongside the existing double-quoted cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:10:02 -05:00
Pascal THUET
e5df517ddc ci: pin actions to commit SHAs and add shellcheck (#3126)
* ci: pin actions to commit SHAs and add shellcheck

Pin actions/github-script in catalog-assign.yml to a full commit SHA; all
other workflows were already pinned. Add a repo-wide regression test that
every workflow `uses:` ref is pinned to a 40-char commit SHA.

Add a shellcheck job to lint.yml (--severity=error over scripts/bash/*.sh)
and document the local command in CONTRIBUTING.md.

* ci: use repo-standard actions/checkout v7.0.0 in shellcheck job

* ci: shellcheck all tracked shell scripts

Assisted-by: Codex (model: GPT-5, autonomous)

* ci: address workflow hygiene review feedback

Assisted-by: Codex (model: GPT-5, autonomous)
2026-06-24 15:08:16 -05:00
Manfred Riem
b577e6c137 chore: release 0.11.7, begin 0.11.8.dev0 development (#3154)
* chore: bump version to 0.11.7

* chore: begin 0.11.8.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-24 15:04:32 -05:00
Zied Jlassi
b042d2a843 feat(extensions): verify catalog archive sha256 before install (#3080)
* feat(extensions): verify catalog archive sha256 before install

Extension and preset archives were downloaded over HTTPS and unpacked
(with Zip-Slip protection) but their bytes were never checked against a
known digest. Trust rested entirely on TLS and the integrity of the
release host, so a tampered or swapped archive from a compromised
third-party release would be installed silently. Maintainers do not audit
extension code, so consumer-side integrity is the only available defence.

Catalog entries may now pin an optional `sha256` digest. When present, the
downloaded archive is verified before it is written to disk and installed;
a mismatch aborts with a clear error. Entries without `sha256` keep
working unchanged (a DEBUG line records that the download was unverified),
so the change is backwards compatible. The check runs on both download
paths (extensions and presets) via a single shared helper so the two stay
in parity.

- Add `verify_archive_sha256` helper in shared_infra (digest match,
  `sha256:` prefix, case-insensitive; DEBUG log when no digest declared)
- Enforce it in ExtensionCatalog.download_extension and
  PresetCatalog.download_pack, before the archive is written to disk
- Document the optional `sha256` field in the publishing guides
- Tests: helper unit tests + matching/mismatch/no-digest on both paths

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Assisted-by: AI

* fix(extensions): harden sha256 parsing and tidy download test mocks

Follow-up to the review on #3080:

- shared_infra.verify_archive_sha256: strip only a literal `sha256:`
  algorithm prefix (case-insensitive) instead of `split(':', 1)[-1]`,
  which silently dropped any prefix — so `md5:<64-hex>` was accepted as
  if it were a valid SHA-256. Validate that the declared value is exactly
  64 hex characters and raise a clear error otherwise, and compare with
  `hmac.compare_digest` for a constant-time check. Add tests covering a
  malformed digest and a non-`sha256:` prefix (both previously accepted).
- Download test helpers: configure the context-manager mock via
  `__enter__.return_value`/`__exit__.return_value` rather than assigning a
  `lambda s: s`, which is clearer and independent of the invocation arity.

Assisted-by: AI
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>

* fix(extensions): reject a declared-but-empty sha256 instead of skipping verification

verify_archive_sha256 skipped on any falsy expected value, so a present-but-empty digest (e.g. sha256: "" reached via ...get("sha256")) silently disabled the integrity check instead of surfacing the authoring error. Guard on expected is None so only an absent digest skips; blank/whitespace/bare-prefix values fall through to the 64-hex validation and are rejected. Adds a regression test.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

* docs(shared_infra): clarify _SHA256_HEX_RE accepts and normalizes uppercase

The comment described the regex as matching '64 lowercase' hex characters,
but verify_archive_sha256 lowercases the declared value (raw.lower()) before
matching, so an uppercase digest is accepted and normalized rather than
rejected. Clarify the comment to avoid misleading future readers.

Addresses Copilot review feedback on shared_infra.py.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

* test(presets): cover the no-sha256 backwards-compatible path

Address Copilot review: download_pack's optional sha256 verification was
tested for match/mismatch but not the backwards-compatible path where a
catalog entry has no sha256 (pack_info.get("sha256") is None). Add a
no-sha256 test mirroring the extensions coverage so the helper never
silently becomes mandatory for presets.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

---------

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>
2026-06-24 14:52:24 -05:00
Zied Jlassi
f846d6526c fix(workflows): validate requires keys and reject phantom permissions gate (#3079)
* fix(workflows): validate requires keys and reject phantom permissions gate

A workflow's `requires` block was parsed but its keys were never
validated, so a typo or an unsupported key was silently ignored. Most
importantly, authors could write `requires.permissions.shell: true`
expecting a runtime capability gate — but no such gate exists: a `shell`
step always runs with the user's privileges. The declaration gave a
false sense of sandboxing.

`validate_workflow` now accepts only the recognised keys
(`speckit_version`, `integrations`, `tools`, `mcp`) and rejects anything
else, with an explicit error for `requires.permissions` pointing authors
to `gate` steps for approval. Docs and the model comment are updated to
state that `requires` is advisory, not a security boundary.

- Reject non-mapping `requires`, unknown keys, and `requires.permissions`
- Clarify workflows reference + PUBLISHING.md shell-step guidance
- Tests for valid keys, non-mapping, unknown key, and permissions

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Assisted-by: AI

* fix(workflows): address review feedback on requires validation

Follow-up to the review on #3079:

- Guard `requires` validation on `is not None` instead of truthiness so a
  falsy non-mapping value (e.g. `requires: []` or `requires: ''`) is
  reported as an error instead of being silently skipped; `requires:`
  (YAML null) is still treated as an omitted block. Add a regression test.
- Reword the workflows security note so `requires.permissions` is shown
  as rejected/unsupported rather than as a valid example of `requires`.
- Standardize on US spelling (`_RECOGNIZED_REQUIRES_KEYS`, "recognized")
  to match the surrounding code and ease searching.
- Tighten the permissions-rejection test to assert on specific message
  markers (`requires.permissions` and the `gate` guidance) so it fails if
  the validation path or wording drifts.

Assisted-by: AI
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>

* fix(workflows): scope requires validation to workflow keys (drop tools/mcp)

tools and mcp belong to the bundle manifest requires schema (bundler/models/manifest.py, resolved in bundler/services/resolver.py), not the workflow requires validated here. Drop them from _RECOGNIZED_REQUIRES_KEYS and revert the PUBLISHING.md claim that this PR had introduced, so workflow requires only recognizes speckit_version and integrations.

This keeps the existing docs accurate and resolves the inline doc-consistency review comments.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

* refactor(workflows): type WorkflowDefinition.requires as Any pre-validation

self.requires holds the raw parsed value, which before validate_workflow()
runs may be a non-mapping (None for a bare 'requires:', a list for
'requires: []', etc.). Annotating it dict[str, Any] was misleading for
editors/type-checkers; use Any and document that validate_workflow() enforces
the mapping shape.

Addresses Copilot review feedback on engine.py.

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

* fix(workflows): reject YAML-null requires: as a non-mapping

Address Copilot review: validate requires the same way as inputs. A
bare requires: parses as YAML null and was previously treated as an
omitted block, which is inconsistent with inputs and lets a stray
requires: line be silently ignored.

Drop the is-not-None guard and check isinstance(..., dict) directly: an
omitted block still defaults to {} (valid), but a present-but-non-mapping
value -- YAML null, [] or '' -- is now an authoring error that surfaces.

Tests: add YAML-null rejection + an omitted-is-still-valid guard test.
Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>

---------

Signed-off-by: Zied Jlassi <6190550+zied-jlassi@users.noreply.github.com>
Signed-off-by: Zied Jlassi (Architect AI) <6190550+zied-jlassi@users.noreply.github.com>
2026-06-24 14:49:43 -05:00
Quratulain-bilal
37e0e71b4e fix(scripts): use case-sensitive match for acronym retention in PS branch names (#3130)
The branch-name generator keeps a short (<3 char) word only when it
appears in uppercase in the description, treating it as an acronym (the
comment says as much). The bash script uses a case-sensitive grep for
this, but the PowerShell script used -match, which is case-insensitive
by default. As a result every short non-stop word was retained on
PowerShell even when lowercase, so the same description produced
different branch names across the two shells (e.g. 'go AI now' ->
001-go-ai-now on PS vs 001-ai-now on bash).

Switch to -cmatch so the check is case-sensitive and the two shells
agree. Adds parity tests covering a dropped lowercase short word and a
kept uppercase acronym.
2026-06-24 14:02:41 -05:00
Omar
44ef11aa18 feat(integrations): add omp support (#3107)
* feat(integrations): add omp support

* Update updated_at timestamp

* refactor(integrations): delegate omp build_exec_args to base, register in issue templates

Inherit MarkdownIntegration.build_exec_args so omp picks up shared CLI
contract changes (requires_cli gating, extra-args ordering, --model
handling) automatically; only specialize the --mode json flag.

Also add Oh My Pi / omp to the issue-template agent lists so
test_issue_template_agent_lists_match_runtime_integrations passes.

* fix(integrations): use --print + positional prompt for omp argv

OMP's CLI parser treats `-p`/`--print` as a boolean (one-shot mode)
and consumes the prompt as a positional message; the previous
inherited `-p <prompt>` shape worked by accident only because `-p`
ignores its next token. Build the argv explicitly with flags first
and the prompt as a trailing positional, matching upstream args.ts.
2026-06-24 13:44:34 -05:00
Ali jawwad
034fbfcbb4 fix: render valid TOML when a command body contains backslashes (#3135)
render_toml_command() emitted the body inside a multiline *basic* TOML
string ("""..."""), which processes backslash escape sequences. A command
body containing a backslash — e.g. a Windows path like C:\Users\... whose
\U reads as an invalid unicode escape — therefore produced unparseable TOML
("Invalid hex value"), so the generated Gemini/Tabnine command file failed
to load. A body ending in a backslash also silently ate the closing newline
via TOML line-continuation.

Route bodies containing a backslash to the multiline *literal* form
('''...'''), which does not process escapes, or to the escaped basic string
when both triple-quote styles are present. Mirrors the escaping already done
by base.py's TomlIntegration.

Add tests covering a Windows path, a trailing backslash, and the
backslash + both-triple-quote-styles fallback.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:13:44 -05:00
Pascal THUET
8e76ff3d5c harden: reject shell=True in run_command (#3132)
run_command() forwarded shell= straight to subprocess.run, so a caller
passing shell=True would invoke a shell. Reject shell=True with ValueError
(keeping the parameter for signature compatibility) and drop shell= from
both subprocess.run calls.

Enable ruff S602/S604/S605 to flag any future shell=True reintroduction,
annotate the one intentional workflow shell sink with # noqa: S602, and
document the shell-step execution risk in workflows/PUBLISHING.md.
2026-06-24 13:05:21 -05:00
Pascal THUET
b6b74d4ccf docs: add monorepo guide (#3084)
* docs: add monorepo guide

Adds docs/guides/monorepo.md covering per-project .specify/, targeting a member project from the repo root with SPECIFY_INIT_DIR, agent env propagation, the git extension scoping limitation (#3081), and per-project constitutions. Wires it into docs/toc.yml under Development.

* docs: correct monorepo Git guidance

* docs: drop open-issue reference and polish monorepo guide prose

* docs: fix SPECIFY_INIT_DIR error example (absolute path, non-project dir)

* docs: address Copilot wording nits in monorepo guide

* docs: clarify monorepo constitution sharing

Assisted-by: Codex (model: GPT-5, autonomous)
2026-06-23 14:20:28 -05:00
Quratulain-bilal
0ef53eb91f fix(scripts): send check-prerequisites.ps1 errors to stderr (#3123)
* fix(scripts): send check-prerequisites.ps1 errors to stderr

The validation errors and run-hints in check-prerequisites.ps1 were
written with Write-Output, so they went to stdout. This script is
usually run with -Json and its stdout parsed by the agent, so an error
(e.g. missing plan.md) leaves the parser with an error string instead
of JSON. The bash counterpart already writes these to stderr (>&2), as
do the sibling PowerShell scripts (setup-tasks.ps1, common.ps1's
Get-FeaturePathsEnv). Switch the six error/hint lines to
[Console]::Error.WriteLine so stdout stays clean and the two shells
match.

* test(scripts): assert check-prerequisites errors stay on stderr

Per the #3122 bug assessment, tighten the failure-path tests so they
verify stdout stays clean (empty / valid JSON) and the error text only
appears on stderr, instead of checking the combined stdout+stderr
string. Covers all three PowerShell validation paths (missing feature
dir, missing plan.md, missing tasks.md with -RequireTasks) and the bash
counterpart. The two new error-routing tests fail on the pre-fix
script (errors on stdout) and pass after it.
2026-06-23 14:09:17 -05:00
JinHyuk Sung
0c975bbef7 fix: write Codex dev skills as files (#2988)
* fix: write Codex dev skills as files

* fix: route codex dev symlink policy through metadata

* fix: replace codex dev symlinks on refresh

* fix: migrate codex dev skill symlinks

* fix: avoid inactive shared skill dev symlinks

* fix: preserve unrelated dev skill symlinks
2026-06-23 10:55:38 -05:00
Manfred Riem
59ffa918df chore: release 0.11.6, begin 0.11.7.dev0 development (#3121)
* chore: bump version to 0.11.6

* chore: begin 0.11.7.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-23 09:47:17 -05:00
github-actions[bot]
45423d6bc6 [extension] Update Spec Kit Preview extension to v1.1.0 and sync Firebender agent lists (#3116)
* Update Spec Kit Preview extension to v1.1.0

Update preview extension submitted by @bigsmartben to:
- extensions/catalog.community.json (version, name, description, download_url, commands, tags, updated_at)
- docs/community/extensions.md community extensions table (name, description, alphabetical order)

Closes #3109

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Sync issue templates with firebender integration

Assisted-by: GitHub Copilot (model: GPT-5, autonomous)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com>
2026-06-23 09:32:16 -05:00
github-actions[bot]
a86ee0e8b6 Add Spec Kit Discovery Extension to community catalog (#3119)
Add discovery extension submitted by @bigsmartben to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3113

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 08:30:21 -05:00
github-actions[bot]
8c85919f0f Update Architecture Workflow extension to v1.2.1 (#3118)
Update arch extension submitted by @bigsmartben to:
- extensions/catalog.community.json (version, download_url, description, provides.commands)
- docs/community/extensions.md community extensions table

Closes #3111

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 08:30:01 -05:00
YNan_varamor
3cfc81ff31 docs: clarify project-defined constitution articles (#2994)
Co-authored-by: yann lei <yann.lei@hotmail.com>
2026-06-23 08:27:26 -05:00
github-actions[bot]
2344eafdd9 Add Intake extension to community catalog (#3117)
Add intake extension submitted by @bigsmartben to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3110

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 08:16:15 -05:00
Ali jawwad
0a126256e0 feat: add Firebender integration (Android Studio / IntelliJ) (#3077)
* feat: add Firebender integration (Android Studio / IntelliJ)

Firebender (https://firebender.com/) is an AI coding agent for Android
Studio and IntelliJ. It reads project-local custom slash commands from
.firebender/commands/*.mdc and project rules from .firebender/rules/*.mdc.

Add a FirebenderIntegration (MarkdownIntegration) that installs the
speckit command templates as .mdc command files and writes the managed
context section into .firebender/rules/specify-rules.mdc. command_filename
is overridden so init-time commands also use the .mdc extension Firebender
requires. Register it in the integration registry, add the catalog entry
and docs row, and add an integration test covering the .mdc command output.

Closes #1548

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: address review - bump catalog updated_at and list firebender as multi-install safe

Bump the catalog top-level updated_at to reflect the new entry, and add firebender (with its .firebender/commands + .firebender/rules/specify-rules.mdc isolation paths) to the 'currently declared multi-install safe integrations' table in the docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 08:01:00 -05:00
github-actions[bot]
2bd97543cc Update DocGuard — CDD Enforcement extension to v0.28.0 (#3115)
Update docguard extension submitted by @raccioly:
- extensions/catalog.community.json (version, download_url, updated_at)
- docs/community/extensions.md community extensions table (no changes needed)

Closes #3106

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 07:46:19 -05:00
WOLIKIMCHENG
ac4f646144 chore: sync issue template agent lists (#3052)
* chore: sync issue template agent lists

* test: harden agent template consistency check

* test: harden agent template drift checks

---------

Co-authored-by: root <kinsonnee@gmail.com>
2026-06-23 07:41:58 -05:00
José Villaseñor Montfort
e5a03bffc8 fix(shared-infra): remove stale managed scripts the core no longer ships (#3076) (#3098)
* fix(shared-infra): remove stale managed scripts the core no longer ships (#3076)

install_shared_infra never removed shared scripts a prior (pre-refactor) install recorded but the current core no longer ships — e.g. the legacy scripts/<variant>/update-agent-context.sh, superseded by the bundled agent-context extension. On a legacy project the orphan lingers and crashes when it sources a refreshed common.sh (HAS_GIT unbound under set -u).

Apply the stale-removal that integration_upgrade already performs to install_shared_infra: manifest-tracked scripts the current bundle no longer produces are removed, but only managed copies (hash matches the manifest); user-customized files, symlinks, and recovered entries are preserved. Guarded so a missing/empty source can't trigger mass deletion, and the safe-destination check prevents unlinking through a symlinked ancestor.

Add IntegrationManifest.remove(); drop the stale update-agent-context.sh reference in CONTRIBUTING.md.

AI assistance: implemented with Claude Code (Anthropic); reviewed and validated locally (ruff clean, full suite 4176 passed, manual CLI repro).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(shared-infra): harden stale-cleanup per review (empty source + orphan manifest)

- Set scripts_scanned only after a real source file is seen, so an empty variant source can't trigger mass deletion of tracked scripts.
- Prune a stale manifest entry even when its file is already gone from disk, keeping the manifest consistent (previously left tracked forever).
- Add a test for each edge case.

Addresses the Copilot review comments on #3098. AI assistance: Claude Code (Anthropic), reviewed/validated locally (ruff clean, full suite 4178 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(shared-infra): guard unsafe manifest keys in stale-cleanup (review)

- Skip absolute / '..' manifest keys before any filesystem access in stale-cleanup, so a corrupted/hand-edited manifest can't make it touch paths outside the project root (mirrors IntegrationManifest.check_modified / uninstall).
- Clarify the scripts_scanned comment: the safety hinge is that flag, not seen_rels (which also holds template paths).
- Add a containment test: a traversal manifest key is skipped, its target untouched.

Addresses the second round of Copilot review on #3098. AI assistance: Claude Code (Anthropic); validated locally (ruff clean, full suite 4179 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manifest): make remove() reject absolute/.. keys like its siblings (review)

IntegrationManifest.remove() now applies the same lexical validation and normalization as record_existing() / is_recovered(): absolute paths and '..' segments are rejected (return False) instead of being used verbatim as a key. Keeps the manifest API consistent. Adds tests (valid drop + no-op, absolute rejected, traversal rejected).

Addresses the third round of Copilot review on #3098. AI assistance: Claude Code (Anthropic); validated locally (ruff clean, full suite 4182 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(shared-infra): validate stale-cleanup keys for containment, not just lexically (review)

The stale-script cleanup guarded manifest keys with a lexical check only
(is_absolute() / ".." segments). On Windows a drive-relative key such as
"C:tmp\\file" is not is_absolute(), yet joining it onto the project path
discards the root — so cleanup could stat/unlink outside the project before
_ensure_safe_shared_destination raised, and a corrupted manifest key turned
into an install-time hard failure (ValueError) instead of being skipped.

Reuse the canonical containment helper (_validate_rel_path, the same one
IntegrationManifest.is_recovered / remove use): after the fast lexical reject,
resolve the join and confirm it stays within the project root; a key that
still escapes is skipped, never unlinked, never fatal.

Adds a regression test that forces _validate_rel_path to reject a managed key
(portably simulating the Windows drive-relative escape) and asserts the
install skips it without failing and still installs the real scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:36:54 -05:00
Manfred Riem
3c11f4d90b chore: release 0.11.5, begin 0.11.6.dev0 development (#3105)
* chore: bump version to 0.11.5

* chore: begin 0.11.6.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-22 17:52:26 -05:00
Pascal THUET
ce01877610 fix: register enabled extensions for agent on integration use/upgrade (#2949)
* fix: register enabled extensions for agent on integration install/upgrade

install and upgrade only set up the integration's own core commands; only
switch re-registered the enabled extensions' commands for the target agent.
A second integration added via install (or refreshed via upgrade) was
therefore silently missing the extension commands the existing agents
already had (e.g. the bundled agent-context extension).

Extract switch's registration into a shared _register_extensions_for_agent
helper and call it from install and upgrade too, so every installed agent
ends up with every enabled extension's commands — full parity with switch.

Closes #2886

* test: pin skills-mode secondary-agent registration; document #2948 limitation

Extension skill rendering is scoped to the active agent (init-options track a
single ai / ai_skills pair), so a skills-mode agent registered while not active
(e.g. Copilot --skills installed as a secondary integration) gets command files
rather than skills. install/upgrade match extension add here; only switch
renders skills, because it activates the target first.

Add a regression test pinning this behavior and document the limitation on the
shared helper. Per-agent skills parity is tracked separately in #2948.

* fix: don't re-render the active agent's skills when registering a non-active agent

register_enabled_extensions_for_agent runs an active-agent-scoped skills pass
(_register_extension_skills resolves the skills dir from init-options["ai"],
ignoring the passed agent). Routing install/upgrade of a secondary integration
through it re-rendered the *active* skills-mode agent's extension skills as a
side effect — resurrecting skill files the user had deliberately deleted. Gate
the skills pass on the target being the active agent; switch is unaffected
because it activates the target first.

Also harden the skills-mode install test (assert a core skill so --skills is
load-bearing, drop a vacuous registered_skills assertion) and add a regression
test. Surfaced by review of the PR; skills parity for non-active agents stays
tracked in #2948.

* refactor: share the extension-op scaffold and run (un)registration post-commit

Review cleanups, no behavior change on the success path:

- Extract the best-effort ExtensionManager scaffold (lazy import, instantiate,
  except -> _print_cli_warning) into _best_effort_extension_op. Both
  _register_extensions_for_agent and a new _unregister_extensions_for_agent
  delegate to it, removing the duplicate block left inline in switch.
- Invoke the best-effort extension registration AFTER the install/switch/upgrade
  try/except has committed, so a failure in it can never trigger the rollback
  (install and switch teardown on except).

* docs: clarify extension registration parity scope

* fix(integrations): defer extension registration until use

* fix(tests): remove redundant shutil import

* fix(integrations): backfill extensions for installed switch targets
2026-06-22 17:48:55 -05:00
github-actions[bot]
afe7657d2c Add SicarioSpec Core preset to community catalog (#3102)
Add sicario-core preset submitted by @SiCar10mw to:
- presets/catalog.community.json (alphabetical order)
- docs/community/presets.md community presets table

Closes #3101

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-22 17:10:00 -05:00
github-actions[bot]
5224f33d7d Update Game Narrative Writing preset to v1.1.0 (#3099)
Update game-narrative-writing preset submitted by @adaumann:
- presets/catalog.community.json (version, download_url, description, provides, tags, updated_at)
- docs/community/presets.md community presets table

Closes #3096

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-22 16:15:28 -05:00
Manfred Riem
a233f3a67b feat: add PyPI publishing workflow and readme metadata (#2915)
* feat: add PyPI publishing workflow and readme metadata

- Add readme = "README.md" to pyproject.toml for PyPI project description
- Add manual publish-pypi.yml workflow using trusted publishers (OIDC)
- Update release.yml install instructions to prefer PyPI

The publish workflow is manually triggered after a release, checks out the
specified tag, verifies version consistency, builds with uv, and publishes
using trusted publishing (no API tokens required).

Prerequisites before first use:
- Take ownership of the specify-cli PyPI project (#2908)
- Create a 'pypi' environment in repo settings
- Configure trusted publisher on PyPI for this repo/workflow

Closes #2908

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

* fix: address PR review feedback on publish workflow

- Add actions: read permission (required for artifact upload/download)
- Move version check after uv install and use uv run python (ensures
  Python >=3.11 with tomllib is available regardless of runner image)

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

* fix: use absolute URLs for README images (PyPI compatibility)

PyPI does not host images from the repository, so relative paths like
./media/logo.webp render as broken images. Switch to absolute
raw.githubusercontent.com URLs so images display on both GitHub and PyPI.

Ref: https://github.com/pypi/warehouse/issues/5246

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

* fix: address second review round

- Convert remaining /media/ image path to absolute URL for PyPI
- Pin release install to specific version (specify-cli==X.Y.Z)
- Align setup-uv to v8.2.0 matching rest of CI

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

* fix: address third review round

- Use job-level permissions: actions:write on build (for upload-artifact),
  actions:read on publish (for download-artifact)
- Include both @latest and pinned version in release notes
- Add note that PyPI may lag behind the GitHub release

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

* fix: add contents:read to build job, clarify manual publish

- Build job needs contents:read for checkout (job-level perms replace
  workflow-level)
- Clarify that PyPI publishing is manually triggered, not automatic

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

* fix: force tag resolution and validate before checkout

Move tag format validation before checkout and use refs/tags/ prefix
to ensure we always check out a tag, not a branch with the same name.

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

* fix: address review - links, install cmd, python pin

- Convert all relative .md links in README to absolute GitHub URLs
  for PyPI rendering compatibility
- Fix release notes: use 'uv tool install specify-cli' (no @latest)
- Pin Python 3.13 via uv python install for deterministic builds
  and use python3 directly instead of uv run

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

* fix: address review - python setup, docs alignment, publish flag

- Use actions/setup-python (pinned v6, Python 3.13) instead of
  uv python install for deterministic builds
- Use python instead of python3 for setup-python compatibility
- Remove unsupported --trusted-publishing always flag from uv publish
  (OIDC is auto-detected with id-token: write)
- Update README install to lead with PyPI, source as fallback
- Update installation guide: replace PyPI disclaimer with official
  package note, add PyPI as primary install method
- Release notes: pin to exact version, clarify PyPI timing

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

* fix: clarify PyPI availability timing in docs

- README: note source install is useful when PyPI version lags
- Installation guide: explain PyPI follows GitHub releases and may
  lag briefly; source installs are always immediately available

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

* fix: quote version specifier in release notes install command

uv tool install accepts PEP 508 specifiers when quoted. Add quotes
around 'specify-cli==VERSION' so users can copy-paste directly.

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

* fix: use specify-cli@latest consistently

Use @latest to force a fresh PyPI resolve (bypasses uv's cached tool
version), matching the issue acceptance criteria. Source install remains
as fallback when PyPI lags.

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

* fix: pin release notes to exact version, clarify manual publish

Release notes (versioned changelog) must always reference the specific
release version, not @latest. Use 'specify-cli==VERSION' for
reproducibility.

Also clarify that PyPI publishing is 'performed after' (not 'follows')
each release, making the manual nature clearer.

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

* fix: keep source install as primary, PyPI as alternative

Until PyPI ownership is fully transferred and first publish is
confirmed, source installs from GitHub remain the primary recommended
method. PyPI install is listed as a convenient alternative.

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

* fix: align checkout pin, soften PyPI wording, absolute links

- Align actions/checkout to v7.0.0 (same SHA as test.yml/release.yml)
- Remove assertion that PyPI is published by maintainers (ownership
  transfer still pending); keep as availability statement
- Use 'once published for this release' wording in release notes
- Convert remaining relative links in README to absolute URLs for
  PyPI rendering

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

* fix: align docs and release notes with pre-transfer state

- docs/installation.md: qualify PyPI as available 'once official
  publishing is enabled' (ownership transfer still pending)
- release.yml: use specify-cli@VERSION syntax (consistent with
  README/docs @latest form)
- PR description updated to match

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

* fix: revert release notes to match main

The release.yml release notes template should not change in this PR.
PyPI install instructions can be added to release notes in a future
PR once publishing is confirmed working.

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

* fix: revert README and installation docs to match main

Do not mention PyPI in documentation until the first official PyPI
release has been published. This PR only adds the workflow and readme
metadata in pyproject.toml.

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

* fix: fail fast if build produces no artifacts

Add if-no-files-found: error to upload-artifact so a missing/empty
dist/ directory fails the build job immediately rather than causing
a confusing failure in the publish job.

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

* fix: align artifact action pins with repo lockfiles

Update upload-artifact to v7.0.1 and download-artifact to v8.0.1,
matching the pins used in the repo's gh-aw workflow lockfiles.

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

---------

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-22 15:58:55 -05:00
darion-yaphet
826e193cee refactor: move extension command handlers to extensions/_commands.py (PR-7/8) (#3014)
* refactor: move extension command handlers to extensions/_commands.py (PR-7/8)

Convert the flat extensions.py module into an extensions/ package and
extract all extension_app and catalog_app command handlers plus their
private helpers (_resolve_installed_extension, _resolve_catalog_extension,
_print_extension_info) out of __init__.py into the new
extensions/_commands.py, mirroring the domain-dir layout used for
presets/_commands.py (PR-6) and integrations/_commands.py (PR-5).

- extensions.py -> extensions/__init__.py (pure rename, 99%); intra-module
  relative imports bumped from `.x` to `..x` since they reference root
  siblings.
- Root helpers (_require_specify_project, _locate_bundled_extension,
  load_init_options, _display_project_path) are reached through thin shims
  that re-fetch from the parent package at call time, so test
  monkeypatching of specify_cli.<helper> keeps working unchanged.
- __init__.py drops ~1444 lines (3511 -> 2067); CLI surface preserved via
  register(app).

No behavior change. Full suite failure set is identical before/after
(82 pre-existing env failures, 0 new).

* fix(extensions): preserve per-command path in update backup for skills agents

Skills agents (extension == "/SKILL.md") name every command file SKILL.md,
each in its own per-command subdir (e.g. speckit-plan/SKILL.md). The update
backup keyed the backup path on cmd_file.name alone, so all of an agent's
skill files collided onto a single backup path — each shutil.copy2 overwrote
the previous one, and rollback restored one skill's content over all the
others, corrupting or losing the rest.

Mirror the real on-disk layout by using cmd_file.relative_to(commands_dir),
keeping each backup path unique. This also makes backed_up_command_files
values unique so restore copies the correct content back to each command.

Add a regression test asserting two distinct skill files survive a
backup -> failed-update -> rollback cycle with their own content.

* style(extensions): use yaml.safe_dump when writing catalog config

The catalog add/remove handlers wrote the integration catalog config with
yaml.dump. Switch to yaml.safe_dump to align with the SafeDumper used by the
presets commands and to refuse emitting !!python/object tags if a non-basic
value ever reaches the config dict.

Output is unchanged for the current basic-type payload (str/int/bool/dict/
list) — this is a defensive/consistency change, not a behavioral fix.

* fix(extensions): correct _print_cli_warning import path in skill registration

register_enabled_extensions_for_agent imported _print_cli_warning from `.` (the extensions package), but the helper lives in the parent specify_cli package. The wrong level raised ImportError inside the error handlers, aborting extension/skill registration on the first failure instead of warning and continuing. Use `..` to match the other parent-package imports.

* fix(extensions): escape untrusted values in Rich markup output

User-provided arguments and extension/catalog metadata (names, descriptions, versions, IDs, paths) were interpolated into Rich markup strings without escaping. Values containing markup sequences (e.g. [red]...) would be parsed as markup, allowing output injection that could corrupt or mislead CLI messages.

Wrap all such interpolations with rich.markup.escape across the extension/catalog command handlers: list, search, info (_print_extension_info), add (including --dev paths), remove, enable, disable, set-priority, update, and the ambiguous-match resolvers (error strings and Table rows). Reuse the already-computed safe_extension where available.

Escaping is a no-op for benign strings, so normal output is unchanged.

* Prevent Rich markup injection in extension CLI output

User-controlled catalog URLs and extension IDs are rendered through Rich-enabled console paths, so every remaining output-only interpolation now escapes markup while leaving stored values and filesystem behavior unchanged. Regression tests cover catalog add, install hints, remove hints, and state command messages with bracketed markup-like values.

* Prevent markup injection from exception text

Rich markup remains enabled for styled CLI messages, so exception text and config path labels must be escaped before rendering. YAML parser errors, URL validation failures, download errors, and extension validation errors can include user-controlled catalog or manifest values.

Constraint: Preserve existing exception handling and user-facing error paths

Rejected: Disable Rich markup for these messages | existing output intentionally uses markup for labels and styling

Confidence: high

Scope-risk: narrow

Directive: Escape user-controlled exception text before interpolating into Rich-rendered strings

Tested: .venv/bin/python -m pytest tests/test_extensions.py -q

Co-authored-by: OmX <omx@oh-my-codex.dev>

* Prevent path and manifest review regressions

Catalog path labels are rendered through Rich markup and downloaded update manifests are trusted long enough to validate extension IDs. Escape displayed project paths before rendering, and reject non-mapping extension.yml payloads before ID validation so bad archives fail with a clear rollback reason.

---------

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-06-22 13:40:57 -05:00
meymchen
6a3ee9b64e feat: add ZCode (Z.AI) integration (#3063)
* feat: add ZCode (Z.AI) integration

Add a skills-based integration for ZCode, Z.AI's Claude-Code-style
agent. ZCode uses the same SKILL.md layout as Claude Code, so spec-kit
installs workflows into .zcode/skills/speckit-<name>/SKILL.md, invoked
in chat as $speckit-<name>.

- ZcodeIntegration(SkillsIntegration) with .zcode/ folder and --skills option
- Register in INTEGRATION_REGISTRY
- Catalog entry (tags: cli, skills, z-ai)
- Tests via SkillsIntegrationTests mixin
- Document in integrations reference and README

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

* fix: render $speckit-* invocations for ZCode skills

ZCode is documented as a skills agent invoked with $speckit-<command>,
but the central invocation rendering only special-cased codex, so
specify init Next Steps and extension hooks rendered the dotted
/speckit.<command> form instead.

Centralize the $speckit-* decision in a DOLLAR_SKILLS_AGENTS set with an
is_dollar_skills_agent() helper, and route both init Next Steps and
HookExecutor._render_hook_invocation through it. Add ZCode invocation
regression tests mirroring the existing Codex/Kimi coverage.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:14:18 -05:00
Austin Z.
bbdf1b8f40 fix(agent-context): support multiple context files safely (#2969)
* fix(agent-context): support multiple context files safely

* fix(agent-context): harden context file validation

* fix(agent-context): preserve disabled context target

* fix(agent-context): address review follow-ups

* fix(agent-context): dedupe PowerShell context files

* fix(agent-context): align context file dedupe

* fix(agent-context): align bash context file dedupe

* fix(agent-context): preserve disabled display target

* fix(agent-context): require yaml-capable updater python

* fix(agent-context): preserve context files config

* fix(agent-context): align context file fallbacks

* fix(agent-context): share context file resolution

---------

Co-authored-by: AustinZ21 <AustinZ21@users.noreply.github.com>
2026-06-22 12:10:55 -05:00
github-actions[bot]
cac16dd1d7 Update DocGuard — CDD Enforcement extension to v0.27.0 (#3094)
Update docguard extension submitted by @raccioly:
- extensions/catalog.community.json (version, download_url, updated_at)

Closes #3093

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-22 11:37:04 -05:00
Manfred Riem
79a34b892d fix(presets): use _repo_root() for bundled-core source-checkout fallback (#3086) (#3091)
* fix(presets): use _repo_root() for bundled-core source-checkout fallback

The tier-5 fallback in PresetResolver.resolve() and
_find_bundled_core() computed the repo root as
Path(__file__).parent.parent.parent. After presets.py was moved to
presets/__init__.py (#2826) that chain is one level short, resolving
to src/ and looking for src/templates/commands/<cmd>.md, which never
exists. As a result, wrap-strategy presets found no core base layer in
source/editable installs.

Use the shared _repo_root() helper so both fallbacks resolve against
the actual repo-root templates/ tree. Wheel installs were unaffected
(core_pack path), so this only impacts source/editable checkouts.

Refs #3086

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

* test(presets): restore dropped def for oserror-manifest test

A prior edit accidentally removed the
def test_resolve_extension_command_via_manifest_skips_oserror_manifests
line, orphaning its body inside the new bundled-core test. Restore the
test definition so pytest collects it again.

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

* test(presets): move bundled-core tests into TestPresetResolver

The two tier-5 fallback regression tests exercise collect_all_layers()
and resolve(), not resolve_core(), so they belong in TestPresetResolver
rather than TestResolveCore. Relocate them for clearer suite navigation.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-22 11:33:44 -05:00
Manfred Riem
5012ba4613 chore: release 0.11.4, begin 0.11.5.dev0 development (#3092)
* chore: bump version to 0.11.4

* chore: begin 0.11.5.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-22 11:14:48 -05:00
110 changed files with 8786 additions and 2102 deletions

View File

@@ -88,9 +88,9 @@ fi
run_command "$kiro_binary --help > /dev/null"
echo "✅ Done"
echo -e "\n🤖 Installing Kimi CLI..."
echo -e "\n🤖 Installing Kimi Code CLI..."
# https://code.kimi.com
run_command "pipx install kimi-cli"
run_command "npm install -g @moonshot-ai/kimi-code@latest"
echo "✅ Done"
echo -e "\n🤖 Installing CodeBuddy CLI..."

View File

@@ -8,7 +8,7 @@ body:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI, Devin for Terminal
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed
- type: input
id: agent-name

View File

@@ -62,24 +62,42 @@ body:
label: AI Agent
description: Which AI agent are you using?
options:
- Amp
- Antigravity
- Auggie CLI
- Claude Code
- Cline
- CodeBuddy
- Codex CLI
- Cursor
- Devin for Terminal
- Firebender
- Forge
- Gemini CLI
- GitHub Copilot
- Cursor
- Qwen Code
- opencode
- Codex CLI
- Windsurf
- Kilo Code
- Auggie CLI
- Roo Code
- CodeBuddy
- Qoder CLI
- Kiro CLI
- Amp
- SHAI
- Goose
- Hermes Agent
- IBM Bob
- Antigravity
- iFlow CLI
- Junie
- Kilo Code
- Kimi Code
- Kiro CLI
- Lingma
- Mistral Vibe
- Oh My Pi
- opencode
- Pi Coding Agent
- Qoder CLI
- Qwen Code
- Roo Code
- RovoDev ACLI
- SHAI
- Tabnine CLI
- Trae
- Windsurf
- ZCode
- Zed
- Not applicable
validations:
required: true

View File

@@ -56,24 +56,42 @@ body:
description: Does this feature relate to a specific AI agent?
options:
- All agents
- Amp
- Antigravity
- Auggie CLI
- Claude Code
- Cline
- CodeBuddy
- Codex CLI
- Cursor
- Devin for Terminal
- Firebender
- Forge
- Gemini CLI
- GitHub Copilot
- Cursor
- Qwen Code
- opencode
- Codex CLI
- Windsurf
- Kilo Code
- Auggie CLI
- Roo Code
- CodeBuddy
- Qoder CLI
- Kiro CLI
- Amp
- SHAI
- Goose
- Hermes Agent
- IBM Bob
- Antigravity
- iFlow CLI
- Junie
- Kilo Code
- Kimi Code
- Kiro CLI
- Lingma
- Mistral Vibe
- Oh My Pi
- opencode
- Pi Coding Agent
- Qoder CLI
- Qwen Code
- Roo Code
- RovoDev ACLI
- SHAI
- Tabnine CLI
- Trae
- Windsurf
- ZCode
- Zed
- Not applicable
- type: textarea

View File

@@ -77,6 +77,18 @@ body:
validations:
required: true
- type: input
id: documentation
attributes:
label: Documentation URL
description: |
Link to the README that explains how to use **this preset** (not a general product/framework pitch).
Prefer the preset-scoped README (e.g. `presets/<id>/README.md` in a monorepo) over the repository root README.
It must contain at least one valid `specify preset add ...` install command — ideally `specify preset add --from <download-url>` using the exact Download URL above (other forms such as `specify preset add <preset-id>` or `specify preset add --dev <path>` are also accepted).
placeholder: "https://github.com/your-org/spec-kit-presets/blob/main/presets/your-preset/README.md"
validations:
required: true
- type: input
id: license
attributes:
@@ -175,7 +187,7 @@ body:
options:
- label: Valid `preset.yml` manifest included
required: true
- label: README.md with description and usage instructions
- label: Linked README (Documentation URL) explains how to use this preset and includes a valid `specify preset add ...` command (preferably `specify preset add --from <download-url>` using the exact download URL)
required: true
- label: LICENSE file included
required: true

View File

@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b4ba1db5fdec754fa825cc3160879924118bc454a781eed70ef6c90beab83a95","body_hash":"392ace500b7cb9b0aa6b020d150841de398bcbcfe54dbad729f0d860d698bde2","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b4ba1db5fdec754fa825cc3160879924118bc454a781eed70ef6c90beab83a95","body_hash":"cb6c19088fa13da0a8320c174e8c14c4887d2c8a005a5cb2d2d2faa3f890de39","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
#

View File

@@ -73,6 +73,7 @@ fields):
| Author | `author` | Yes |
| Repository URL | `repository` | Yes |
| Download URL | `download-url` | Yes |
| Documentation URL | `documentation` | Yes |
| License | `license` | Yes |
| Required Spec Kit Version | `speckit-version` | Yes |
| Required Extensions | `required-extensions` | No |
@@ -100,17 +101,70 @@ deciding pass/fail:
### 2c. Repository validation
- Fetch the repository URL — confirm it exists and is publicly accessible
- Confirm the repository contains a `preset.yml` file
- Confirm the repository contains a `README.md` file
- Confirm the repository contains a `LICENSE` file
### 2d. Release and download URL validation
> The README requirement is enforced once, in **Step 2d**, against the specific file the
> `documentation` field points to — not a generic repository-root `README.md`. This avoids
> the monorepo false-positive where a root README exists but isn't the preset-usage doc.
### 2d. Documentation README validation
The `documentation` field must point to the README that explains **how to use this
preset** — not just any file named `README.md`, and not a product/framework pitch.
- **Restrict the URL to GitHub before fetching.** The `documentation` value is
user-provided input. Only accept GitHub-hosted README URLs:
- `https://github.com/<owner>/<repo>/blob/<ref>/<path>`
- `https://github.com/<owner>/<repo>/raw/<ref>/<path>`
- `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`
If the URL points anywhere else (or isn't a URL), **fail this check** and do not fetch it.
- **Require the URL to point at a README file.** After stripping any fragment/query (see
below), the URL path must end with `README.md` (case-insensitive). If it points at some
other Markdown file, **fail this check** and ask the submitter to link the preset's README.
- Fetch the **exact URL** in the `documentation` field. First strip any fragment (`#...`)
or query string (`?...`) — these are common when copying from the browser UI and must be
ignored so the fetch target is deterministic. Then resolve the raw content to fetch:
- For a `github.com/<owner>/<repo>/blob/<ref>/<path>` URL, fetch the equivalent
`github.com/<owner>/<repo>/raw/<ref>/<path>` URL (only swap `/blob/``/raw/`).
- Fetch `github.com/.../raw/...` and `raw.githubusercontent.com/...` URLs as-is.
Do **not** rewrite into `raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>` form — that
format can't reliably represent refs containing slashes (e.g. a `feature/foo` branch).
Confirm the fetched URL resolves to a readable Markdown file.
- **Validate that the README contains a valid Spec Kit CLI install command.** The fetched
README must contain at least one `specify preset add ...` invocation. The strongest
signal is the catalog-install form whose URL matches the submitted **Download URL**:
- `specify preset add --from <download-url>` (preferred), or
- `specify preset add <preset-id>`, or
- `specify preset add --dev <path>`
A `specify preset add --from <url>` command only counts when its `<url>` **matches the
submitted Download URL exactly**. A `--from` command pointing at a *different* URL does
**not** satisfy the install-command requirement (treat it as if absent) — but the README
may still pass on one of the other accepted forms (`specify preset add <preset-id>` or
`specify preset add --dev <path>`).
If **no** accepted `specify preset add ...` command is present, the README is treated as a
generic description/pitch rather than preset-usage documentation — **fail this check** and
tell the submitter to add a valid install command (ideally
`specify preset add --from <download-url>`).
- **Prefer a preset-scoped README in monorepos.** If `documentation` resolves to a generic
repository-root README in a monorepo (the preset lives in a subdirectory such as
`presets/<id>/` and a preset-scoped README exists there), **flag it** in your comment and
recommend the submitter point `documentation` at the preset-scoped README
(e.g. `presets/<id>/README.md`) so the catalog surfaces usage instead of marketing. Treat
this as a flag rather than a hard failure **only if** the root README still contains a valid
`specify preset add ...` command for this preset; otherwise it fails check 2d above.
### 2e. Release and download URL validation
- The download URL should follow the pattern
`https://github.com/<owner>/<repo>/archive/refs/tags/v<version>.zip`
or
`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`
- Verify a GitHub release exists matching the submitted version
### 2e. Submission checklists
### 2f. Submission checklists
- Confirm that all required checkboxes in the Testing Checklist and Submission
Requirements sections are checked (`[x]`)
@@ -154,7 +208,7 @@ Insert the entry in **alphabetical order by preset ID** within the
"repository": "<repository>",
"download_url": "<download_url>",
"homepage": "<homepage or repository>",
"documentation": "<documentation or repository README>",
"documentation": "<documentation URL — the validated preset-usage README>",
"license": "<license>",
"requires": {
"speckit_version": "<speckit_version>"

View File

@@ -19,7 +19,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const issue = context.payload.issue;

View File

@@ -42,3 +42,15 @@ jobs:
globs: |
'**/*.md'
!extensions/**/*.md
shellcheck:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# shellcheck is preinstalled on ubuntu-latest runners.
# Start at --severity=error to block real bugs without flagging style
# (notably SC2155). Tighten in a follow-up after cleanup.
- name: Run shellcheck on shell scripts
run: git ls-files -z -- '*.sh' | xargs -0 shellcheck --severity=error

80
.github/workflows/publish-pypi.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: Publish to PyPI
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish (e.g., v0.10.1)'
required: true
type: string
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
steps:
- name: Verify tag format
run: |
TAG="${{ inputs.tag }}"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: '$TAG' is not a valid release tag (expected vX.Y.Z)"
exit 1
fi
- name: Checkout release tag
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: refs/tags/${{ inputs.tag }}
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13"
- name: Verify tag matches package version
run: |
TAG_VERSION="${{ inputs.tag }}"
TAG_VERSION="${TAG_VERSION#v}"
PROJECT_VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"
if [[ "$TAG_VERSION" != "$PROJECT_VERSION" ]]; then
echo "Error: Tag version ($TAG_VERSION) does not match pyproject.toml version ($PROJECT_VERSION)"
exit 1
fi
- name: Build package
run: uv build
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
actions: read
steps:
- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Publish to PyPI
run: uv publish

View File

@@ -19,7 +19,7 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13"
@@ -40,7 +40,7 @@ jobs:
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python-version }}

View File

@@ -2,6 +2,84 @@
<!-- insert new changelog below this comment -->
## [0.11.9] - 2026-06-26
### Changed
- Docs: add cline and zcode to multi-install-safe table (#3180)
- Docs: document missing flags --force and --refresh-shared-infra (#3179)
- fix(claude): stop forking /speckit-analyze to prevent long-session freezes (#3188)
- fix: derive plan path from feature.json in update-agent-context (#3069)
- fix(catalog): companion → README docs, version-pinned download URL, v0.11.0, refreshed tags (#2954)
- chore(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#3173)
- Update SicarioSpec Core preset to v0.5.1 (#3165)
- fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157)
- Update preset composition strategy reference (#3143)
- fix(scripts): keep PowerShell branch-name acronym match case-sensitive (parity with bash) (#3129)
- fix(extensions): tell agent to run mandatory hooks, not just emit the directive (#2901)
- Point sicario-core docs to preset README (#3120)
- chore: release 0.11.8, begin 0.11.9.dev0 development (#3156)
## [0.11.8] - 2026-06-24
### Changed
- docs: add SpecKit Assistant npm package to Community Friends (#3142)
- Require preset-usage README with Spec Kit CLI syntax in preset submissions (#3104)
- [extension] Update Jira Integration (Sync Engine) extension to v0.4.0 (#3152)
- Add Spec Roadmap extension to community catalog (#3153)
- feat(integration): update Kimi integration for Kimi Code CLI (#2979)
- [extension] Add Golden Demo extension to community catalog (#3151)
- docs: run /speckit.checklist after /speckit.plan in quickstart (#3108)
- fix(workflows): preserve commas inside quoted list-literal elements (#3134)
- ci: pin actions to commit SHAs and add shellcheck (#3126)
- chore: release 0.11.7, begin 0.11.8.dev0 development (#3154)
## [0.11.7] - 2026-06-24
### Changed
- feat(extensions): verify catalog archive sha256 before install (#3080)
- fix(workflows): validate requires keys and reject phantom permissions gate (#3079)
- fix(scripts): use case-sensitive match for acronym retention in PS branch names (#3130)
- feat(integrations): add omp support (#3107)
- fix: render valid TOML when a command body contains backslashes (#3135)
- harden: reject shell=True in run_command (#3132)
- docs: add monorepo guide (#3084)
- fix(scripts): send check-prerequisites.ps1 errors to stderr (#3123)
- fix: write Codex dev skills as files (#2988)
- chore: release 0.11.6, begin 0.11.7.dev0 development (#3121)
## [0.11.6] - 2026-06-23
### Changed
- [extension] Update Spec Kit Preview extension to v1.1.0 and sync Firebender agent lists (#3116)
- Add Spec Kit Discovery Extension to community catalog (#3119)
- Update Architecture Workflow extension to v1.2.1 (#3118)
- docs: clarify project-defined constitution articles (#2994)
- Add Intake extension to community catalog (#3117)
- feat: add Firebender integration (Android Studio / IntelliJ) (#3077)
- Update DocGuard — CDD Enforcement extension to v0.28.0 (#3115)
- chore: sync issue template agent lists (#3052)
- fix(shared-infra): remove stale managed scripts the core no longer ships (#3076) (#3098)
- chore: release 0.11.5, begin 0.11.6.dev0 development (#3105)
## [0.11.5] - 2026-06-22
### Changed
- fix: register enabled extensions for agent on integration use/upgrade (#2949)
- Add SicarioSpec Core preset to community catalog (#3102)
- Update Game Narrative Writing preset to v1.1.0 (#3099)
- feat: add PyPI publishing workflow and readme metadata (#2915)
- refactor: move extension command handlers to extensions/_commands.py (PR-7/8) (#3014)
- feat: add ZCode (Z.AI) integration (#3063)
- fix(agent-context): support multiple context files safely (#2969)
- Update DocGuard — CDD Enforcement extension to v0.27.0 (#3094)
- fix(presets): use _repo_root() for bundled-core source-checkout fallback (#3086) (#3091)
- chore: release 0.11.4, begin 0.11.5.dev0 development (#3092)
## [0.11.4] - 2026-06-22
### Changed

View File

@@ -113,6 +113,16 @@ uv pip install -e ".[test]"
> `specify_cli` to this checkout's `src/`. This matches the gotcha documented in
> `AGENTS.md` (Common Pitfalls).
#### Shell scripts
```bash
git ls-files -z -- '*.sh' | xargs -0 shellcheck --severity=error
```
The CI `lint.yml` `shellcheck` job currently reports and blocks only
error-severity findings. Warnings such as SC2155 are intentionally outside this
job until a follow-up cleanup tightens the threshold.
### Manual testing
#### Testing setup
@@ -167,7 +177,7 @@ the command templates in templates/commands/ to understand what each command
invokes. Use these mapping rules:
- templates/commands/X.md → the command it defines
- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected
- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh), then every command invoking those downstream scripts is also affected
- templates/Z-template.md → every command that consumes that template during execution
- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify
- extensions/X/commands/* → the extension command it defines

View File

@@ -403,7 +403,7 @@ specify init . --force --integration copilot
specify init --here --force --integration copilot
```
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, or Mistral Vibe installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
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, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode 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> --integration copilot --ignore-agent-tools

View File

@@ -31,7 +31,7 @@ The following community-contributed extensions are available in [`catalog.commun
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
| Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
| Architecture Workflow | Generate or reverse project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
| Architecture Workflow | Generate or reverse project-level 4+1 architecture views as separate commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
@@ -56,8 +56,9 @@ The following community-contributed extensions are available in [`catalog.commun
| Fleet Orchestrator | Orchestrate a full feature lifecycle with human-in-the-loop gates across all SpecKit phases | `process` | Read+Write | [spec-kit-fleet](https://github.com/sharathsatish/spec-kit-fleet) |
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Golden Demo | Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD | `docs` | Read+Write | [spec-kit-golden-demo](https://github.com/jasstt/spec-kit-golden-demo) |
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
| Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Intake | Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
@@ -110,11 +111,14 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) |
| 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 Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
| Spec 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 Roadmap | Capture a durable spec roadmap after the constitution, then review specs against it before and after implementation so spec-specific decisions, outcomes, and constraints are never lost. | `process` | Read+Write | [speckit-roadmap](https://github.com/srobroek/speckit-roadmap) |
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| Spec Trace | Build a requirement → test traceability matrix from spec.md and the test suite — surface untested requirements and orphan tests | `code` | Read+Write | [spec-kit-trace](https://github.com/Quratulain-bilal/spec-kit-trace) |

View File

@@ -7,7 +7,9 @@ Community projects that extend, visualize, or build on Spec Kit:
- **[cc-spex](https://github.com/rhuss/cc-spex)** — A Claude Code plugin that adds composable traits on top of Spec Kit with [Superpowers](https://github.com/obra/superpowers)-based quality gates, spec/code review, git worktree isolation, and parallel implementation via agent teams.
- **[Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH.
- **[VS Code Spec Kit Assistant](https://marketplace.visualstudio.com/items?itemName=rfsales.speckit-assistant)** — A VS Code extension that provides a visual orchestrator for the full SDD workflow (constitution → specification → planning → tasks → implementation) with phase status visualization, an interactive task checklist, DAG visualization, and support for Claude, Gemini, GitHub Copilot, and OpenAI backends. Requires the `specify` CLI in your PATH.
- **[SpecKit Assistant](https://www.npmjs.com/package/speckit-assistant)** — A visual orchestrator for Spec-Driven Development (SDD). It connects your local specification, planning, and task checklists with AI agents (Claude, Gemini, GitHub Copilot). No global installation required — just run it via `npx speckit-assistant`.
- **[SpecKit Companion](https://marketplace.visualstudio.com/items?itemName=alfredoperez.speckit-companion)** — A VS Code extension that brings a visual GUI to Spec Kit. Browse specs in a rich markdown viewer with clickable file references, create specifications with image attachments, comment and refine each step inline (GitHub-style review), track your progress through the SDD workflow with a visual phase stepper, and manage steering documents like constitutions and templates.

View File

@@ -17,7 +17,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Cross-Platform Governance | Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
@@ -25,6 +25,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |

111
docs/guides/monorepo.md Normal file
View File

@@ -0,0 +1,111 @@
# Using Spec Kit in a Monorepo
A Spec Kit project is **directory-scoped**: the project is whichever directory
contains `.specify/`. A monorepo can hold several independent Spec Kit projects
under one repository root, each with its own `.specify/`, `specs/`, constitution,
and feature numbering.
Root resolution already prefers the **nearest** `.specify/` over the Git
toplevel, so commands run from inside a member project resolve to that project,
not the repo root.
## Layout
```text
my-monorepo/
├── .git/ # one Git repository at the root
├── apps/
│ ├── web/
│ │ └── .specify/ # Spec Kit project "web"
│ │ └── memory/constitution.md
│ └── api/
│ └── .specify/ # Spec Kit project "api"
│ └── memory/constitution.md
└── packages/
└── ui/
└── .specify/ # Spec Kit project "ui"
```
Initialize each member project independently:
```bash
specify init apps/web --integration claude
specify init apps/api --integration claude
```
Each project keeps its own `specs/` directory and numbers features
independently (`apps/web/specs/001-…`, `apps/api/specs/001-…`).
## Working inside a member project
The default workflow is unchanged: change into the project directory and run the
slash commands. Root resolution finds the nearest `.specify/`.
```bash
cd apps/web
# then run /speckit.specify, /speckit.plan, … in your agent
```
## Targeting a member project from the repo root
For non-interactive or CI runs where you do not want to `cd`, set
**`SPECIFY_INIT_DIR`** to the member project root (the directory *containing*
`.specify/`). Relative paths resolve against the current directory.
```bash
# operate on apps/web from the monorepo root (no cd required)
export SPECIFY_INIT_DIR=apps/web
```
The path must exist and contain `.specify/`. If it does not, the command
**errors and does not fall back** to the current directory or the Git toplevel.
This is deliberate: a typo never writes specs into the wrong project. A
nonexistent path is reported as you typed it; a path that exists but is not a
Spec Kit project is reported as its resolved absolute path:
```text
# SPECIFY_INIT_DIR=apps/wbe (typo: no such directory)
ERROR: SPECIFY_INIT_DIR does not point to an existing directory: apps/wbe
# SPECIFY_INIT_DIR=apps (exists, but has no .specify/ of its own)
ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): /home/you/my-monorepo/apps
```
`SPECIFY_INIT_DIR` selects the **project**; `SPECIFY_FEATURE_DIRECTORY` selects
the **feature** within it. They compose: set both to pick a project and a
feature non-interactively. See the
[`SPECIFY_INIT_DIR` reference](../reference/core.md#environment-variables) for
the full contract and the two-axes model.
## How `SPECIFY_INIT_DIR` reaches your agent
`SPECIFY_INIT_DIR` is read by the shell scripts that the slash commands invoke
(`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell). It takes effect only
when it is present in the environment of the shell that runs those scripts.
- **Scripted / CI runs:** export it in the same shell that drives the commands;
it is reliable there.
- **Interactive agents:** whether an exported variable reaches the shell tool an
agent uses is agent-specific. Export `SPECIFY_INIT_DIR` *before* launching the
agent, and verify once (e.g. run `/speckit.specify` and confirm the new feature
landed under the intended project's `specs/`).
## Git in a monorepo
> [!NOTE]
> Spec Kit project files are scoped to the **resolved project root**, but Git
> operations still run in the containing Git work tree. In a monorepo with a
> single Git repository at the root and projects in subdirectories, feature
> branch creation creates or switches branches in the shared root repository.
> Spec directories still live under the selected member project, while the Git
> branch namespace is shared by the whole monorepo. Manage branches and commits
> at the repository root, or initialize Git per member project if you want
> isolated per-project branch namespaces.
## Constitutions
Each member project has its own `.specify/memory/constitution.md` and
`/speckit.constitution` edits the local project's file. Spec Kit does not provide
a built-in base/inheritance mechanism; if you want one constitution to reference
shared rules elsewhere in the monorepo, you need to maintain that wiring yourself.
Otherwise, duplicate or sync shared engineering rules per project.

View File

@@ -3,7 +3,7 @@
## Prerequisites
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi Coding Agent](https://pi.dev), or [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent)
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
@@ -51,6 +51,7 @@ specify init <project_name> --integration gemini
specify init <project_name> --integration copilot
specify init <project_name> --integration codebuddy
specify init <project_name> --integration pi
specify init <project_name> --integration omp
```
### Specify Script Type (Shell vs PowerShell)

View File

@@ -13,10 +13,10 @@ This guide will help you get started with Spec-Driven Development using Spec Kit
After installing Spec Kit and defining your project constitution, quick experiments can use the lean feature path: `/speckit.specify` -> `/speckit.plan` -> `/speckit.tasks` -> `/speckit.implement`. For production features or any work with meaningful ambiguity, treat `/speckit.clarify`, `/speckit.checklist`, and `/speckit.analyze` as regular quality gates:
```text
/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.checklist -> /speckit.plan -> /speckit.tasks -> /speckit.analyze -> /speckit.implement
/speckit.constitution -> /speckit.specify -> /speckit.clarify -> /speckit.plan -> /speckit.checklist -> /speckit.tasks -> /speckit.analyze -> /speckit.implement
```
Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` to validate requirements quality before planning, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted.
Use `/speckit.clarify` to reduce requirement ambiguity before planning, `/speckit.checklist` (after `/speckit.plan`) to generate quality checklists that validate requirements completeness, clarity, and consistency, and `/speckit.analyze` to check spec/plan/task consistency before implementation starts. You can repeat `/speckit.analyze` after implementation as an extra review, but keep the first analysis before `/speckit.implement` so gaps are caught while the plan and tasks can still be adjusted.
### Step 1: Install Specify
@@ -75,12 +75,6 @@ uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME
/speckit.clarify Focus on security and performance requirements.
```
Then validate the requirements with `/speckit.checklist` before creating the technical plan:
```bash
/speckit.checklist
```
### Step 5: Create a Technical Implementation Plan
**In the chat**, use the `/speckit.plan` slash command to provide your tech stack and architecture choices.
@@ -89,6 +83,12 @@ Then validate the requirements with `/speckit.checklist` before creating the tec
/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
```
Then generate quality checklists with `/speckit.checklist` once the plan exists:
```bash
/speckit.checklist
```
### Step 6: Break Down, Analyze, and Implement
**In the chat**, use the `/speckit.tasks` slash command to create an actionable task list.
@@ -150,15 +150,7 @@ You can continue to refine the spec with more details using `/speckit.clarify`:
/speckit.clarify When you first launch Taskify, it's going to give you a list of the five users to pick from. There will be no password required. When you click on a user, you go into the main view, which displays the list of projects. When you click on a project, you open the Kanban board for that project. You're going to see the columns. You'll be able to drag and drop cards back and forth between different columns. You will see any cards that are assigned to you, the currently logged in user, in a different color from all the other ones, so you can quickly see yours. You can edit any comments that you make, but you can't edit comments that other people made. You can delete any comments that you made, but you can't delete comments anybody else made.
```
### Step 4: Validate the Spec
Validate the specification checklist using the `/speckit.checklist` command:
```bash
/speckit.checklist
```
### Step 5: Generate Technical Plan with `/speckit.plan`
### Step 4: Generate Technical Plan with `/speckit.plan`
Be specific about your tech stack and technical requirements:
@@ -166,6 +158,14 @@ Be specific about your tech stack and technical requirements:
/speckit.plan We are going to generate this using .NET Aspire, using Postgres as the database. The frontend should use Blazor server with drag-and-drop task boards, real-time updates. There should be a REST API created with a projects API, tasks API, and a notifications API.
```
### Step 5: Validate the Spec
Generate quality checklists to validate the specification using the `/speckit.checklist` command:
```bash
/speckit.checklist
```
### Step 6: Define Tasks
Generate an actionable task list using the `/speckit.tasks` command:

View File

@@ -69,6 +69,33 @@ Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes.
}
```
### GitHub Enterprise Server (GHES)
To use a private catalog or extension hosted on a GitHub Enterprise Server
instance, add a `github` entry listing your GHES host(s). The same entry
authenticates both catalog JSON fetches **and** private release-asset
downloads — Specify recognizes the listed hosts as GitHub Enterprise and
resolves release downloads through the GHES REST API (`/api/v3`).
```json
{
"providers": [
{
"hosts": ["ghes.example.com", "raw.ghes.example.com", "codeload.ghes.example.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_ENTERPRISE_TOKEN"
}
]
}
```
List the **bare** web host (e.g. `ghes.example.com`) — release-download URLs
live there. If your instance uses subdomain isolation, also list the `raw.`
and `codeload.` subdomains your catalog/extension URLs use. A
`*.ghes.example.com` wildcard matches subdomains but **not** the bare host,
so always include the bare host explicitly.
### Azure DevOps (`azure-devops`)
| Scheme | Header | Use for |

View File

@@ -26,6 +26,7 @@ specify extension add <name>
| --------------- | -------------------------------------------------------- |
| `--dev` | Install from a local directory (for development) |
| `--from <url>` | Install from a custom URL instead of the catalog |
| `--force` | Overwrite if already installed |
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |
Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.

View File

@@ -15,6 +15,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
@@ -24,10 +25,11 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [iFlow CLI](https://docs.iflow.cn/en/cli/quickstart) | `iflow` | |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; supports `--migrate-legacy` for dotted→hyphenated directory migration |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths, and (when the `agent-context` extension is enabled) migrates `KIMI.md` context into `AGENTS.md` |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
| [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` |
| [opencode](https://opencode.ai/) | `opencode` | |
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
@@ -38,6 +40,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Tabnine CLI](https://docs.tabnine.com/main/getting-started/tabnine-cli) | `tabnine` | |
| [Trae](https://www.trae.ai/) | `trae` | Skills-based integration; skills are installed automatically |
| [Windsurf](https://windsurf.com/) | `windsurf` | |
| [ZCode](https://zcode.z.ai/) | `zcode` | Skills-based integration; installs skills into `.zcode/skills/` and invokes them as `$speckit-<command>` |
| [Zed](https://zed.dev/) | `zed` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-<command>` |
| Generic | `generic` | Bring your own agent — use `--integration generic --integration-options="--commands-dir <path>"` for AI coding agents not listed above |
@@ -97,6 +100,7 @@ specify integration switch <key>
| ------------------------ | ------------------------------------------------------------------------ |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) |
| `--integration-options` | Options for the target integration when it is not already installed |
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`.
@@ -155,7 +159,7 @@ Some integrations accept additional options via `--integration-options`:
| Integration | Option | Description |
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format |
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated directory names); when the `agent-context` extension is enabled, also migrates `KIMI.md` to `AGENTS.md` |
Example:
@@ -181,14 +185,15 @@ The currently declared multi-install safe integrations are:
| --- | --------- |
| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` |
| `claude` | `.claude/skills`, `CLAUDE.md` |
| `cline` | `.clinerules/workflows`, `.clinerules/specify-rules.md` |
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
| `codex` | `.agents/skills`, `AGENTS.md` |
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
| `gemini` | `.gemini/commands`, `GEMINI.md` |
| `iflow` | `.iflow/commands`, `IFLOW.md` |
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
| `kimi` | `.kimi/skills`, `KIMI.md` |
| `qodercli` | `.qoder/commands`, `QODER.md` |
| `qwen` | `.qwen/commands`, `QWEN.md` |
| `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` |
@@ -196,6 +201,7 @@ The currently declared multi-install safe integrations are:
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
| `windsurf` | `.windsurf/workflows`, `.windsurf/rules/specify-rules.md` |
| `zcode` | `.zcode/skills`, `ZCODE.md` |
Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.

View File

@@ -137,9 +137,11 @@ catalogs:
## File Resolution
Presets can provide command files, template files (like `plan-template.md`), and script files. These are resolved at runtime using a **replace** strategy — the first match in the priority stack wins and is used entirely. Each file is looked up independently, so different files can come from different layers.
Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers.
> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release.
Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into detected agent directories instead of being re-resolved by agents. During preset install, Spec Kit registers command files for the preset being installed; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Agents do not re-resolve the stack each time they run a command.
By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder.
The resolution stack, from highest to lowest precedence:
@@ -148,8 +150,6 @@ The resolution stack, from highest to lowest precedence:
3. **Installed extensions** — sorted by priority
4. **Spec Kit core**`.specify/templates/`
Commands are registered at install time (not resolved through the stack at runtime).
### Resolution Stack
```mermaid
@@ -215,7 +215,7 @@ Run `specify preset resolve <name>` to trace the resolution stack and see which
### What's the difference between disabling and removing a preset?
**Disabling** (`specify preset disable`) keeps the preset installed but excludes its files from the resolution stack. Commands the preset registered remain available in your AI coding agent. This is useful for temporarily testing behavior without a preset, or comparing output with and without it. Re-enable anytime with `specify preset enable`.
**Disabling** (`specify preset disable`) keeps the preset installed but excludes it from future template and script resolution. Previously registered commands remain available in your AI coding agent until preset removal, so use removal when you need command changes to stop taking effect. Disabling is useful for temporarily testing template/script behavior without a preset, or comparing template/script output with and without it. Re-enable anytime with `specify preset enable`.
**Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry.

View File

@@ -270,6 +270,8 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
| `fan-out` | Dispatch a step for each item in a list |
| `fan-in` | Aggregate results from a fan-out step |
> **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands.
## Expressions
Steps can reference inputs and previous step outputs using `{{ expression }}` syntax:

View File

@@ -53,6 +53,8 @@
href: local-development.md
- name: Evolving Specs
href: guides/evolving-specs.md
- name: Monorepos
href: guides/monorepo.md
# Community
- name: Community

View File

@@ -308,6 +308,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
ls -la .gemini/commands/ # Gemini
ls -la .cursor/skills/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
ls -la .omp/commands/ # Oh My Pi
```
3. **Check agent-specific setup:**
@@ -427,7 +428,7 @@ The `specify` CLI tool is used for:
- **Upgrades:** `specify init --here --force` to update templates and commands
- **Diagnostics:** `specify check` to verify tool installation
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, `.omp/commands/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
**If your agent isn't recognizing slash commands:**
@@ -442,6 +443,9 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
# For Pi
ls -la .pi/prompts/
# For Oh My Pi
ls -la .omp/commands/
```
2. **Restart your IDE/editor completely** (not just reload window)

View File

@@ -320,6 +320,7 @@ A: Extensions should be free and open-source. Commercial support/services are al
"author": "string (required)",
"version": "string (required, semver)",
"download_url": "string (required, valid URL)",
"sha256": "string (optional, SHA-256 hex digest of the archive at download_url; verified before install)",
"repository": "string (required, valid URL)",
"homepage": "string (optional, valid URL)",
"documentation": "string (optional, valid URL)",

View File

@@ -10,6 +10,7 @@ Not every Spec Kit user wants Spec Kit to write into the coding agent's context
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value.
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
## Commands
@@ -27,6 +28,12 @@ All configuration flows through the extension's own config file at
# Path to the coding agent context file managed by this extension
context_file: CLAUDE.md
# Optional list of coding agent context files to manage together.
# When non-empty, this takes precedence over context_file.
context_files:
- AGENTS.md
- CLAUDE.md
# Delimiters for the managed Spec Kit section
context_markers:
start: "<!-- SPECKIT START -->"
@@ -34,6 +41,7 @@ context_markers:
```
- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
## Requirements
@@ -55,3 +63,4 @@ specify extension disable agent-context
```
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out.

View File

@@ -2,12 +2,17 @@
# These values are populated automatically by `specify init` and
# `specify integration use` / `specify integration install`.
# Path (relative to the project root) to the coding agent context file
# Path (relative to the project root) to the default coding agent context file
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
# .github/copilot-instructions.md). Set automatically from the active
# integration and regenerated during `specify init` or integration switches.
context_file: ""
# Optional list of project-relative coding agent context files managed by this
# extension. When non-empty, this list takes precedence over `context_file`.
# Use this for projects that intentionally keep multiple agent anchors in sync.
context_files: []
# Delimiters for the managed Spec Kit section.
# Edit these to use custom markers.
context_markers:

View File

@@ -1,5 +1,5 @@
---
description: "Refresh the managed Spec Kit section in the coding agent context file"
description: "Refresh the managed Spec Kit section in coding agent context file(s)"
---
# Update Coding Agent Context
@@ -12,11 +12,12 @@ The script reads the agent-context extension config at
`.specify/extensions/agent-context/agent-context-config.yml` to discover:
- `context_file` — the path of the coding agent context file to manage.
- `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`.
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.
If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected.
## Execution

View File

@@ -1,18 +1,18 @@
#!/usr/bin/env bash
# update-agent-context.sh
#
# Refresh the managed Spec Kit section in the coding agent's context file
# Refresh the managed Spec Kit section in the coding agent's context file(s)
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
#
# Reads `context_file` and `context_markers.{start,end}` from the
# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the
# agent-context extension config:
# .specify/extensions/agent-context/agent-context-config.yml
#
# Usage: update-agent-context.sh [plan_path]
#
# When `plan_path` is omitted, the script picks the most recently modified
# `specs/*/plan.md` if any exist, otherwise emits the section without a
# concrete plan path.
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
set -euo pipefail
@@ -26,22 +26,41 @@ if [[ ! -f "$EXT_CONFIG" ]]; then
exit 0
fi
# Locate a suitable Python interpreter (python3, then python).
# Locate a Python 3 interpreter with PyYAML available.
_python=""
if command -v python3 >/dev/null 2>&1; then
_python="python3"
elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then
_python="python"
fi
_python_candidates=()
[[ -n "${SPECKIT_PYTHON:-}" ]] && _python_candidates+=("$SPECKIT_PYTHON")
_python_candidates+=("python3" "python")
for _candidate in "${_python_candidates[@]}"; do
if command -v "$_candidate" >/dev/null 2>&1 \
&& "$_candidate" - <<'PY' >/dev/null 2>&1
import sys
try:
import yaml # noqa: F401
except ImportError:
sys.exit(1)
sys.exit(0 if sys.version_info[0] == 3 else 1)
PY
then
_python="$_candidate"
break
fi
done
unset _candidate _python_candidates
if [[ -z "$_python" ]]; then
echo "agent-context: Python 3 not found on PATH; skipping update." >&2
echo "agent-context: Python 3 with PyYAML not found on PATH; skipping update." >&2
echo " To resolve: pip install pyyaml (or install it into the environment used by python3)." >&2
exit 0
fi
_case_insensitive_context_files=0
case "$(uname -s 2>/dev/null || true)" in
MINGW*|MSYS*|CYGWIN*) _case_insensitive_context_files=1 ;;
esac
# Parse extension config once; emit three newline-separated fields:
# context_file, context_markers.start, context_markers.end
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY'
# Parse extension config once; emit context files as JSON, followed by marker strings.
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY'
import json
import sys
try:
import yaml
@@ -73,7 +92,28 @@ def get_str(obj, *keys):
else:
return ""
return node if isinstance(node, str) else ""
print(get_str(data, "context_file"))
context_files = []
seen_context_files = set()
case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin"))
raw_files = data.get("context_files")
if isinstance(raw_files, list):
for value in raw_files:
if not isinstance(value, str):
continue
candidate = value.strip()
if not candidate:
continue
key = candidate.casefold() if case_insensitive else candidate
if key in seen_context_files:
continue
context_files.append(candidate)
seen_context_files.add(key)
if not context_files:
raw_file = get_str(data, "context_file")
candidate = raw_file.strip()
if candidate:
context_files.append(candidate)
print(json.dumps(context_files))
print(get_str(data, "context_markers", "start"))
print(get_str(data, "context_markers", "end"))
PY
@@ -87,31 +127,71 @@ while IFS= read -r _line || [[ -n "$_line" ]]; do
_opts_lines+=("$_line")
done < <(printf '%s\n' "$_raw_opts")
if (( ${#_opts_lines[@]} < 3 )); then
echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
echo "agent-context: malformed config parser output; expected 3 lines (context_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
exit 0
fi
CONTEXT_FILE="${_opts_lines[0]}"
CONTEXT_FILES_JSON="${_opts_lines[0]}"
MARKER_START="${_opts_lines[1]}"
MARKER_END="${_opts_lines[2]}"
if [[ -z "$CONTEXT_FILE" ]]; then
echo "agent-context: context_file not set in extension config; nothing to do." >&2
if ! _context_files_raw="$("$_python" - "$CONTEXT_FILES_JSON" <<'PY'
import json
import sys
try:
data = json.loads(sys.argv[1])
except Exception:
data = []
if not isinstance(data, list):
data = []
for value in data:
if isinstance(value, str) and value:
print(value)
PY
)"; then
echo "agent-context: malformed context_files parser output; skipping update." >&2
exit 0
fi
# Reject absolute paths, backslash separators, and '..' path segments in context_file
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2
exit 1
CONTEXT_FILES=()
while IFS= read -r _line || [[ -n "$_line" ]]; do
[[ -n "$_line" ]] && CONTEXT_FILES+=("$_line")
done < <(printf '%s\n' "$_context_files_raw")
if (( ${#CONTEXT_FILES[@]} == 0 )); then
echo "agent-context: context_files/context_file not set in extension config; nothing to do." >&2
exit 0
fi
if [[ "$CONTEXT_FILE" == *\\* ]]; then
echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2
exit 1
fi
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
for _seg in "${_cf_parts[@]}"; do
if [[ "$_seg" == ".." ]]; then
echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
# Reject absolute paths, backslash separators, and '..' path segments in context files
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
echo "agent-context: context files must be project-relative paths; got '$CONTEXT_FILE'." >&2
exit 1
fi
if [[ "$CONTEXT_FILE" == *\\* ]]; then
echo "agent-context: context files must not contain backslash separators; got '$CONTEXT_FILE'." >&2
exit 1
fi
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
for _seg in "${_cf_parts[@]}"; do
if [[ "$_seg" == ".." ]]; then
echo "agent-context: context files must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
exit 1
fi
done
if ! "$_python" - "$PROJECT_ROOT" "$CONTEXT_FILE" <<'PY'
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
target = (root / sys.argv[2]).resolve(strict=False)
try:
target.relative_to(root)
except ValueError:
sys.exit(1)
PY
then
echo "agent-context: context file path resolves outside the project root; got '$CONTEXT_FILE'." >&2
exit 1
fi
done
@@ -122,29 +202,81 @@ unset _cf_parts _seg
PLAN_PATH="${1:-}"
if [[ -z "$PLAN_PATH" ]]; then
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
# Use find + sort by modification time to avoid ls/head fragility with
# spaces in paths or SIGPIPE from pipefail.
_plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
import sys, os
# Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic.
_feature_json="$PROJECT_ROOT/.specify/feature.json"
if [[ -f "$_feature_json" ]]; then
_feature_dir="$("$_python" - "$_feature_json" <<'PY'
import sys, json
try:
with open(sys.argv[1], encoding="utf-8") as fh:
d = json.load(fh)
val = d.get("feature_directory", "")
print(val if isinstance(val, str) else "")
except Exception:
print("")
PY
)"
# Normalize backslashes (written by PS on Windows) to forward slashes before path ops.
_feature_dir="$(printf '%s' "$_feature_dir" | tr '\\' '/')"
_feature_dir="${_feature_dir%/}"
if [[ -n "$_feature_dir" ]]; then
# feature_directory may be relative or absolute (absolute paths outside PROJECT_ROOT
# are preserved as-is by _persist_feature_json in common.sh).
# Also match drive-qualified paths (C:/...) written by PowerShell on Windows.
if [[ "$_feature_dir" == /* ]] || [[ "$_feature_dir" =~ ^[A-Za-z]:/ ]]; then
_candidate="$_feature_dir/plan.md"
else
_candidate="$PROJECT_ROOT/$_feature_dir/plan.md"
fi
if [[ -f "$_candidate" ]]; then
# Resolve symlinks before comparing so paths like /var/… vs /private/var/…
# (macOS) are treated as equivalent. Mirrors the mtime-fallback approach.
PLAN_PATH="$("$_python" - "$PROJECT_ROOT" "$_candidate" <<'PY'
import sys
from pathlib import Path
specs = Path(sys.argv[1]) / "specs"
root = Path(sys.argv[1]).resolve()
cand = Path(sys.argv[2]).resolve()
try:
print(cand.relative_to(root).as_posix())
except ValueError:
# Outside project root: emit the resolved path in POSIX form.
# as_posix() converts backslashes correctly on native Windows Python.
print(cand.as_posix())
PY
)"
fi
fi
fi
# Fall back to mtime only when feature.json is absent or its plan does not exist yet.
# Python emits a project-relative POSIX path directly to avoid bash prefix-strip
# issues with backslash paths on Windows (Git bash / MSYS2).
if [[ -z "$PLAN_PATH" ]]; then
_plan_rel="$("$_python" - "$PROJECT_ROOT" <<'PY'
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
specs = root / "specs"
plans = sorted(
specs.glob("*/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
print(plans[0] if plans else "")
if plans:
try:
print(plans[0].relative_to(root).as_posix())
except ValueError:
print("")
else:
print("")
PY
)"
if [[ -n "$_plan_abs" ]]; then
PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
if [[ -n "$_plan_rel" ]]; then
PLAN_PATH="$_plan_rel"
fi
fi
fi
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
mkdir -p "$(dirname "$CTX_PATH")"
# Build the managed section
TMP_SECTION="$(mktemp)"
trap 'rm -f "$TMP_SECTION"' EXIT
@@ -158,7 +290,11 @@ trap 'rm -f "$TMP_SECTION"' EXIT
echo "$MARKER_END"
} > "$TMP_SECTION"
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
mkdir -p "$(dirname "$CTX_PATH")"
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
import sys, os
ctx_path, start, end, section_path = sys.argv[1:5]
with open(section_path, "r", encoding="utf-8") as fh:
@@ -197,4 +333,5 @@ with open(ctx_path, "wb") as fh:
fh.write(new_content.encode("utf-8"))
PY
echo "agent-context: updated $CONTEXT_FILE"
echo "agent-context: updated $CONTEXT_FILE"
done

View File

@@ -1,14 +1,18 @@
#!/usr/bin/env pwsh
# update-agent-context.ps1
#
# Refresh the managed Spec Kit section in the coding agent's context file
# Refresh the managed Spec Kit section in the coding agent's context file(s)
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
#
# Reads `context_file` and `context_markers.{start,end}` from the
# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the
# agent-context extension config:
# .specify/extensions/agent-context/agent-context-config.yml
#
# Usage: update-agent-context.ps1 [plan_path]
#
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
# (written by /speckit-specify). Falls back to the most recently modified
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
[CmdletBinding()]
param(
@@ -52,6 +56,66 @@ function Test-ConfigObject {
return $false
}
function Resolve-ContextPath {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$RelativePath
)
$rootFull = [System.IO.Path]::GetFullPath($Root)
$segments = $RelativePath -split '/'
$resolved = $rootFull
foreach ($segment in $segments) {
if ([string]::IsNullOrWhiteSpace($segment) -or $segment -eq '.') {
continue
}
$candidate = [System.IO.Path]::GetFullPath((Join-Path $resolved $segment))
if (Test-Path -LiteralPath $candidate) {
$item = Get-Item -LiteralPath $candidate -Force
if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
$target = $item.Target
if ($target -is [System.Array]) {
$target = $target[0]
}
if ($target) {
if ([System.IO.Path]::IsPathRooted($target)) {
$candidate = [System.IO.Path]::GetFullPath($target)
} else {
$candidate = [System.IO.Path]::GetFullPath(
(Join-Path (Split-Path -Parent $candidate) $target)
)
}
}
}
}
$resolved = $candidate
}
return $resolved
}
function Test-IsSubPath {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$Path
)
$comparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) {
[System.StringComparison]::OrdinalIgnoreCase
} else {
[System.StringComparison]::Ordinal
}
$rootFull = [System.IO.Path]::GetFullPath($Root).TrimEnd(
[System.IO.Path]::DirectorySeparatorChar,
[System.IO.Path]::AltDirectorySeparatorChar
)
$pathFull = [System.IO.Path]::GetFullPath($Path)
return $pathFull.Equals($rootFull, $comparison) -or
$pathFull.StartsWith($rootFull + [System.IO.Path]::DirectorySeparatorChar, $comparison)
}
$ErrorActionPreference = 'Stop'
$DefaultStart = '<!-- SPECKIT START -->'
$DefaultEnd = '<!-- SPECKIT END -->'
@@ -66,20 +130,37 @@ if (-not (Test-Path -LiteralPath $ExtConfig)) {
$Options = $null
if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
try {
$Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop
$Options = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 | ConvertFrom-Yaml -ErrorAction Stop
} catch {
# fall through to Python fallback
# fall through to ConvertFrom-Json fallback
}
}
if ($null -eq $Options) {
# ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
# ConvertFrom-Yaml unavailable or failed; try ConvertFrom-Json (no external deps,
# works when the config file is valid JSON, which is a subset of YAML).
try {
$raw = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8
$Options = $raw | ConvertFrom-Json -ErrorAction Stop
if (-not (Test-ConfigObject -Object $Options)) { $Options = $null }
} catch {
$Options = $null
}
}
if ($null -eq $Options) {
# ConvertFrom-Yaml/Json unavailable or failed; fall back to Python+PyYAML.
$pythonCmd = $null
foreach ($candidate in @('python3', 'python')) {
$pythonCandidates = @()
if ($env:SPECKIT_PYTHON) {
$pythonCandidates += $env:SPECKIT_PYTHON
}
$pythonCandidates += @('python3', 'python')
foreach ($candidate in $pythonCandidates) {
if (Get-Command $candidate -ErrorAction SilentlyContinue) {
# Verify it is Python 3
$verOut = & $candidate --version 2>&1
if ($verOut -match 'Python 3') {
# Verify it is Python 3 with PyYAML available.
$null = & $candidate -c "import sys; import yaml; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null
if ($LASTEXITCODE -eq 0) {
$pythonCmd = $candidate
break
}
@@ -87,8 +168,10 @@ if ($null -eq $Options) {
}
if ($pythonCmd) {
$pyScript = $null
try {
$jsonOut = & $pythonCmd -c @'
$pyScript = [System.IO.Path]::GetTempFileName()
Set-Content -LiteralPath $pyScript -Encoding UTF8 -Value @'
import json
import sys
try:
@@ -114,12 +197,17 @@ if not isinstance(data, dict):
data = {}
print(json.dumps(data))
'@ $ExtConfig
'@
$jsonOut = & $pythonCmd $pyScript $ExtConfig
if ($LASTEXITCODE -eq 0 -and $jsonOut) {
$Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop
}
} catch {
$Options = $null
} finally {
if ($pyScript -and (Test-Path -LiteralPath $pyScript)) {
Remove-Item -LiteralPath $pyScript -Force -ErrorAction SilentlyContinue
}
}
}
@@ -134,21 +222,63 @@ if (-not (Test-ConfigObject -Object $Options)) {
exit 0
}
$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file'
if (-not $ContextFile) {
Write-Warning 'agent-context: context_file not set in extension config; nothing to do.'
$ConfiguredContextFiles = Get-ConfigValue -Object $Options -Key 'context_files'
$ContextFiles = @()
if ($null -ne $ConfiguredContextFiles) {
foreach ($item in @($ConfiguredContextFiles)) {
if ($item -is [string] -and -not [string]::IsNullOrWhiteSpace($item)) {
$ContextFiles += $item.Trim()
}
}
}
if ($ContextFiles.Count -eq 0) {
$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file'
if ($ContextFile -is [string] -and -not [string]::IsNullOrWhiteSpace($ContextFile)) {
$ContextFiles += $ContextFile.Trim()
}
}
$pathComparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) {
[System.StringComparer]::OrdinalIgnoreCase
} else {
[System.StringComparer]::Ordinal
}
$seenContextFiles = [System.Collections.Generic.HashSet[string]]::new($pathComparison)
$dedupedContextFiles = @()
foreach ($ContextFile in $ContextFiles) {
if ($seenContextFiles.Add($ContextFile)) {
$dedupedContextFiles += $ContextFile
}
}
$ContextFiles = $dedupedContextFiles
if ($ContextFiles.Count -eq 0) {
Write-Warning 'agent-context: context_files/context_file not set in extension config; nothing to do.'
exit 0
}
# Reject absolute paths and '..' path segments in context_file
if ([System.IO.Path]::IsPathRooted($ContextFile)) {
Write-Warning "agent-context: context_file must be a project-relative path; got '$ContextFile'."
exit 1
}
$cfSegments = $ContextFile -split '[/\\]'
if ($cfSegments -contains '..') {
Write-Warning "agent-context: context_file must not contain '..' path segments; got '$ContextFile'."
exit 1
foreach ($ContextFile in $ContextFiles) {
# Reject absolute paths, drive-qualified paths, backslash separators, and '..' path segments in context files
if ($ContextFile -match '^[A-Za-z]:') {
Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'."
exit 1
}
if ([System.IO.Path]::IsPathRooted($ContextFile)) {
Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'."
exit 1
}
if ($ContextFile.Contains('\')) {
Write-Warning "agent-context: context files must not contain backslash separators; got '$ContextFile'."
exit 1
}
$cfSegments = $ContextFile -split '[/\\]'
if ($cfSegments -contains '..') {
Write-Warning "agent-context: context files must not contain '..' path segments; got '$ContextFile'."
exit 1
}
$resolvedTarget = Resolve-ContextPath -Root $ProjectRoot -RelativePath $ContextFile
if (-not (Test-IsSubPath -Root $ProjectRoot -Path $resolvedTarget)) {
Write-Warning "agent-context: context file path resolves outside the project root; got '$ContextFile'."
exit 1
}
}
$MarkerStart = $DefaultStart
@@ -166,28 +296,70 @@ if ($cm) {
}
if (-not $PlanPath) {
# Discover plan.md exactly one level deep (specs/<feature>/plan.md),
# matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
# $ErrorActionPreference = 'Stop' don't abort the script.
try {
$specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($candidate) {
$PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/')
# Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic.
$FeatureJson = Join-Path $ProjectRoot '.specify/feature.json'
if (Test-Path -LiteralPath $FeatureJson) {
try {
$fj = Get-Content -LiteralPath $FeatureJson -Raw -Encoding UTF8 | ConvertFrom-Json
$featureDir = $fj.feature_directory
if ($featureDir -isnot [string] -or -not $featureDir) {
$featureDir = $null
} else {
$featureDir = $featureDir.TrimEnd('\', '/')
}
if ($featureDir) {
# Join-Path on Unix does not treat absolute ChildPath as "wins"; check explicitly.
if ([System.IO.Path]::IsPathRooted($featureDir)) {
$candidatePlan = Join-Path $featureDir 'plan.md'
} else {
$candidatePlan = Join-Path (Join-Path $ProjectRoot $featureDir) 'plan.md'
}
if (Test-Path -LiteralPath $candidatePlan) {
# Resolve ./ .. segments before relativizing (mirrors bash Path.resolve()).
# GetFullPath is available in .NET Framework 4.x (PS 5.1 compatible).
$resolvedPlan = [System.IO.Path]::GetFullPath($candidatePlan)
$resolvedDir = [System.IO.Path]::GetDirectoryName($resolvedPlan)
$normRoot = $ProjectRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$normDir = $resolvedDir.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
$cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
if ($normDir.StartsWith($normRoot, $cmp)) {
$relDir = $normDir.Substring($normRoot.Length).TrimEnd('\', '/')
$PlanPath = if ($relDir) { $relDir.Replace('\', '/') + '/plan.md' } else { 'plan.md' }
} else {
$PlanPath = $resolvedPlan.Replace('\', '/')
}
}
}
} catch {
# Non-fatal: fall through to mtime heuristic.
}
} catch {
# Non-fatal: continue without a plan path.
}
}
$CtxPath = Join-Path $ProjectRoot $ContextFile
$CtxDir = Split-Path -Parent $CtxPath
if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) {
New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null
# Fall back to mtime only when feature.json is absent or its plan does not exist yet.
if (-not $PlanPath) {
try {
$specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($candidate) {
# GetRelativePath is .NET 5+ only; strip prefix manually for PS 5.1 compat.
# Use case-insensitive comparison on Windows only (matches common.ps1 pattern).
$fullPath = $candidate.FullName.Replace('\', '/')
$normRoot = $ProjectRoot.Replace('\', '/').TrimEnd('/') + '/'
$cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
if ($fullPath.StartsWith($normRoot, $cmp)) {
$PlanPath = $fullPath.Substring($normRoot.Length)
} else {
$PlanPath = $fullPath
}
}
} catch {
# Non-fatal: continue without a plan path.
}
}
}
$lines = @($MarkerStart,
@@ -199,39 +371,47 @@ if ($PlanPath) {
$lines += $MarkerEnd
$Section = ($lines -join "`n") + "`n"
if (Test-Path -LiteralPath $CtxPath) {
$rawBytes = [System.IO.File]::ReadAllBytes($CtxPath)
# Strip UTF-8 BOM if present
if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) {
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3)
} else {
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
foreach ($ContextFile in $ContextFiles) {
$CtxPath = Join-Path $ProjectRoot $ContextFile
$CtxDir = Split-Path -Parent $CtxPath
if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) {
New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null
}
$s = $content.IndexOf($MarkerStart)
$e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) }
if (Test-Path -LiteralPath $CtxPath) {
$rawBytes = [System.IO.File]::ReadAllBytes($CtxPath)
# Strip UTF-8 BOM if present
if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) {
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3)
} else {
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
}
if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) {
$endOfMarker = $e + $MarkerEnd.Length
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
$newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker)
} elseif ($s -ge 0) {
$newContent = $content.Substring(0, $s) + $Section
} elseif ($e -ge 0) {
$endOfMarker = $e + $MarkerEnd.Length
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
$newContent = $Section + $content.Substring($endOfMarker)
$s = $content.IndexOf($MarkerStart)
$e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) }
if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) {
$endOfMarker = $e + $MarkerEnd.Length
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
$newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker)
} elseif ($s -ge 0) {
$newContent = $content.Substring(0, $s) + $Section
} elseif ($e -ge 0) {
$endOfMarker = $e + $MarkerEnd.Length
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
$newContent = $Section + $content.Substring($endOfMarker)
} else {
if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section }
}
} else {
if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section }
$newContent = $Section
}
} else {
$newContent = $Section
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "agent-context: updated $ContextFile"
}
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "agent-context: updated $ContextFile"

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-22T00:00:00Z",
"updated_at": "2026-06-24T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -187,10 +187,10 @@
"arch": {
"name": "Architecture Workflow",
"id": "arch",
"description": "Generate or reverse project-level 4+1 architecture view artifacts and synthesis",
"description": "Generate or reverse project-level 4+1 architecture views as separate commands",
"author": "bigsmartben",
"version": "1.1.0",
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.1.0.zip",
"version": "1.2.1",
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.2.1.zip",
"repository": "https://github.com/bigsmartben/spec-kit-arch",
"homepage": "https://github.com/bigsmartben/spec-kit-arch",
"documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md",
@@ -202,7 +202,7 @@
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"commands": 2,
"commands": 10,
"hooks": 0
},
"tags": [
@@ -215,7 +215,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-14T00:00:00Z",
"updated_at": "2026-05-15T00:00:00Z"
"updated_at": "2026-06-23T00:00:00Z"
},
"architect-preview": {
"name": "Architect Impact Previewer",
@@ -772,40 +772,40 @@
"companion": {
"name": "SpecKit Companion",
"id": "companion",
"description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and a turbo pipeline profile.",
"description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and composable commands you can customize with hooks and recipes.",
"author": "alfredoperez",
"version": "0.3.0",
"download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.3.0/companion-0.3.0.zip",
"version": "0.11.0",
"download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.11.0/companion-0.11.0.zip",
"repository": "https://github.com/alfredoperez/speckit-companion",
"homepage": "https://github.com/alfredoperez/speckit-companion/tree/main/speckit-extension",
"documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/docs/",
"documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/README.md",
"changelog": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/CHANGELOG.md",
"license": "MIT",
"category": "visibility",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.8.5",
"speckit_version": ">=0.9.5",
"tools": [
{ "name": "python3", "required": false }
]
},
"provides": {
"commands": 10,
"commands": 13,
"hooks": 4
},
"tags": [
"tracking",
"companion",
"progress",
"vscode",
"lifecycle",
"resume"
"progress",
"status",
"resume",
"configurable",
"extensible"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-11T00:00:00Z",
"updated_at": "2026-06-11T00:00:00Z"
"updated_at": "2026-06-24T00:00:00Z"
},
"conduct": {
"name": "Conduct Extension",
@@ -1001,13 +1001,47 @@
"created_at": "2026-04-08T00:00:00Z",
"updated_at": "2026-04-08T00:00:00Z"
},
"discovery": {
"name": "Spec Kit Discovery Extension",
"id": "discovery",
"description": "Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation.",
"author": "bigsmartben",
"version": "0.2.0",
"download_url": "https://github.com/bigsmartben/spec-kit-discovery/archive/refs/tags/v0.2.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-discovery",
"homepage": "https://github.com/bigsmartben/spec-kit-discovery",
"documentation": "https://github.com/bigsmartben/spec-kit-discovery/blob/main/docs/usage.md",
"changelog": "https://github.com/bigsmartben/spec-kit-discovery/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 6,
"hooks": 0
},
"tags": [
"discovery",
"workflow",
"validation",
"feasibility",
"decision"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z"
},
"docguard": {
"name": "DocGuard — CDD Enforcement",
"id": "docguard",
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
"author": "raccioly",
"version": "0.26.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.26.0/spec-kit-docguard-v0.26.0.zip",
"version": "0.28.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.0.zip",
"repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -1043,7 +1077,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-06-11T00:00:00Z"
"updated_at": "2026-06-23T00:00:00Z"
},
"doctor": {
"name": "Project Health Check",
@@ -1293,6 +1327,39 @@
"created_at": "2026-04-12T15:30:00Z",
"updated_at": "2026-04-13T14:39:00Z"
},
"golden-demo": {
"name": "Golden Demo",
"id": "golden-demo",
"description": "Extracts acceptance criteria from specs, builds test vectors, and produces a behavioral drift report — complementary to Architecture Guard and CDD.",
"author": "jasstt",
"version": "0.1.1",
"download_url": "https://github.com/jasstt/spec-kit-golden-demo/archive/refs/tags/v0.1.1.zip",
"repository": "https://github.com/jasstt/spec-kit-golden-demo",
"homepage": "https://github.com/jasstt/spec-kit-golden-demo",
"documentation": "https://github.com/jasstt/spec-kit-golden-demo",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 2,
"hooks": 2
},
"tags": [
"testing",
"drift-detection",
"behavioral-oracle",
"tdd",
"quality"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-24T00:00:00Z",
"updated_at": "2026-06-24T00:00:00Z"
},
"harness": {
"name": "Research Harness",
"id": "harness",
@@ -1370,6 +1437,46 @@
"created_at": "2026-06-16T00:00:00Z",
"updated_at": "2026-06-16T00:00:00Z"
},
"intake": {
"name": "Intake",
"id": "intake",
"description": "Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts.",
"author": "bigsmartben",
"version": "0.1.2",
"download_url": "https://github.com/bigsmartben/spec-kit-intake/archive/refs/tags/v0.1.2.zip",
"repository": "https://github.com/bigsmartben/spec-kit-intake",
"homepage": "https://github.com/bigsmartben/spec-kit-intake",
"documentation": "https://github.com/bigsmartben/spec-kit-intake/blob/main/README.md",
"changelog": "https://github.com/bigsmartben/spec-kit-intake/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.8.10.dev0",
"tools": [
{
"name": "figma-mcp",
"required": false
}
]
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"intake",
"sdd",
"requirements",
"validation",
"figma"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z"
},
"issue": {
"name": "GitHub Issues Integration 2",
"id": "issue",
@@ -1474,25 +1581,34 @@
"id": "jira-sync",
"description": "An idempotent, drift-aware, fail-closed reconcile engine that mirrors spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase).",
"author": "Ash Brener",
"version": "0.2.0",
"download_url": "https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/tags/v0.2.0.zip",
"version": "0.4.0",
"download_url": "https://github.com/ashbrener/spec-kit-jira-sync/archive/refs/tags/v0.4.0.zip",
"repository": "https://github.com/ashbrener/spec-kit-jira-sync",
"homepage": "https://github.com/ashbrener/spec-kit-jira-sync",
"documentation": "https://github.com/ashbrener/spec-kit-jira-sync/blob/main/README.md",
"changelog": "https://github.com/ashbrener/spec-kit-jira-sync/releases",
"changelog": "https://github.com/ashbrener/spec-kit-jira-sync/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "integration",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0"
"speckit_version": ">=0.1.0",
"tools": [
{ "name": "bash", "version": ">=4.4", "required": true },
{ "name": "git", "required": true },
{ "name": "curl", "required": true },
{ "name": "jq", "required": true },
{ "name": "gitleaks", "required": false },
{ "name": "trufflehog", "required": false }
]
},
"provides": {
"commands": 2,
"commands": 4,
"hooks": 0
},
"tags": [
"issue-tracking",
"jira",
"tasks-sync",
"lifecycle-mirror",
"reconcile",
"drift-aware"
],
@@ -1500,7 +1616,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-06-08T00:00:00Z",
"updated_at": "2026-06-08T00:00:00Z"
"updated_at": "2026-06-24T00:00:00Z"
},
"learn": {
"name": "Learning Extension",
@@ -2347,12 +2463,12 @@
"updated_at": "2026-03-18T00:00:00Z"
},
"preview": {
"name": "Interactive HTML Preview",
"name": "Spec Kit Preview",
"id": "preview",
"description": "Generate self-contained interactive HTML prototypes from Spec Kit artifacts",
"description": "Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML",
"author": "bigsmartben",
"version": "1.0.0",
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.0.0.zip",
"version": "1.1.0",
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-preview",
"homepage": "https://github.com/bigsmartben/spec-kit-preview",
"documentation": "https://github.com/bigsmartben/spec-kit-preview/blob/main/README.md",
@@ -2364,20 +2480,21 @@
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"commands": 1,
"commands": 6,
"hooks": 0
},
"tags": [
"preview",
"prototype",
"html",
"markdown",
"ux"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-15T00:00:00Z",
"updated_at": "2026-05-15T00:00:00Z"
"updated_at": "2026-06-23T00:00:00Z"
},
"product": {
"name": "Product Spec Extension",
@@ -2887,6 +3004,40 @@
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-20T00:00:00Z"
},
"roadmap": {
"name": "Spec Roadmap",
"id": "roadmap",
"description": "Capture a durable spec roadmap after the constitution, then review specs against it before and after implementation so spec-specific decisions, outcomes, and constraints are never lost.",
"author": "srobroek",
"version": "0.1.0",
"download_url": "https://github.com/srobroek/speckit-roadmap/archive/refs/tags/v0.1.0.zip",
"repository": "https://github.com/srobroek/speckit-roadmap",
"homepage": "https://github.com/srobroek/speckit-roadmap",
"documentation": "https://github.com/srobroek/speckit-roadmap/blob/main/README.md",
"changelog": "https://github.com/srobroek/speckit-roadmap/blob/main/CHANGELOG.md",
"license": "Apache-2.0",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.11.6"
},
"provides": {
"commands": 4,
"hooks": 3
},
"tags": [
"roadmap",
"planning",
"governance",
"review",
"spec-alignment"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-24T00:00:00Z",
"updated_at": "2026-06-24T00:00:00Z"
},
"schedule": {
"name": "Spec Kit Schedule — CP-SAT Agent Orchestrator",
"id": "schedule",

View File

@@ -252,7 +252,10 @@ function Get-BranchName {
if ($stopWords -contains $word) { continue }
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -match "\b$($word.ToUpper())\b") {
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
# Case-sensitive (-cmatch) to mirror the bash twin's `grep -qw -- "${word^^}"`:
# keep a short word only when its UPPERCASE form appears in the original
# (an acronym). -match is case-insensitive and would keep every short word.
$meaningfulWords += $word
}
}

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-02T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -102,6 +102,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"firebender": {
"id": "firebender",
"name": "Firebender",
"version": "1.0.0",
"description": "Firebender IDE integration for Android Studio / IntelliJ",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"forge": {
"id": "forge",
"name": "Forge",
@@ -246,6 +255,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"omp": {
"id": "omp",
"name": "Oh My Pi",
"version": "1.0.0",
"description": "Oh My Pi (omp) terminal coding agent prompt-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"iflow": {
"id": "iflow",
"name": "iFlow CLI",
@@ -299,6 +317,15 @@
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"zcode": {
"id": "zcode",
"name": "ZCode",
"version": "1.0.0",
"description": "Z.AI ZCode CLI skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills", "z-ai"]
}
}
}

View File

@@ -19,7 +19,7 @@ Before publishing a preset, ensure you have:
1. **Valid Preset**: A working preset with a valid `preset.yml` manifest
2. **Git Repository**: Preset hosted on GitHub (or other public git hosting)
3. **Documentation**: README.md with description and usage instructions
3. **Documentation**: A preset-scoped README.md that explains how to use **this preset**, including a valid `specify preset add ...` install command (see [Usage README Requirements](#usage-readme-requirements))
4. **License**: Open source license file (MIT, Apache 2.0, etc.)
5. **Versioning**: Semantic versioning (e.g., 1.0.0)
6. **Testing**: Preset tested on real projects with `specify preset add --dev`
@@ -147,6 +147,46 @@ https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0
specify preset add --from https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip
```
### Usage README Requirements
The catalog `documentation` field must point at a README that explains how to use
**this preset** — not a product pitch for a broader framework or a separate CLI.
The submission workflow **mechanically enforces** that the linked README is a GitHub-hosted
URL whose path ends with `README.md`, resolves to a readable file, and contains at least one
valid `specify preset add ...` command. The remaining items (preferring a preset-scoped README
in monorepos, covering the minimum structure) are expectations a human reviewer checks —
follow them so your submission isn't sent back for changes.
- **Point `documentation` at the preset-scoped README.** In a monorepo where the preset
lives in a subdirectory (e.g. `presets/<id>/`), link the README inside that directory
(`presets/<id>/README.md`) rather than the repository-root README. The root README is
often a marketing/overview page; the catalog should surface preset usage instead. The key
requirement is that this README is reachable at the `documentation` URL so users can read
it *before* downloading the release artifact — it's fine for the same file to also ship
inside the release ZIP.
- **Include a valid Spec Kit CLI install command** *(enforced)*. The linked README must
contain at least one `specify preset add ...` invocation. Preferably use the
catalog-install form whose URL matches your Download URL:
```bash
# <download-url> is the same URL you submit as the catalog Download URL —
# either the tag archive or a release asset, e.g.:
specify preset add --from https://github.com/<org>/<repo>/archive/refs/tags/vX.Y.Z.zip
specify preset add --from https://github.com/<org>/<repo>/releases/download/vX.Y.Z/<id>-X.Y.Z.zip
```
`specify preset add <id>` and `specify preset add --dev <path>` are also accepted, but the
`--from <download-url>` form is the clearest signal that the README documents this exact
preset release.
- **Cover the minimum structure** so a reader can decide whether the preset fits:
- What the preset does / what it provides
- The install command using Spec Kit CLI syntax (above)
- When to use it / when not to use it
A submission whose linked README lacks a valid `specify preset add ...` command **fails
validation** (workflow check 2d) and will not be added until corrected.
---
## Submit to Catalog
@@ -181,11 +221,14 @@ Edit `presets/catalog.community.json` and add your preset.
"presets": {
"your-preset": {
"name": "Your Preset Name",
"id": "your-preset",
"description": "Brief description of what your preset provides",
"author": "Your Name",
"version": "1.0.0",
"download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip",
"sha256": "OPTIONAL: SHA-256 hex digest of the archive above; verified before install",
"repository": "https://github.com/your-org/spec-kit-preset-your-preset",
"documentation": "https://github.com/your-org/spec-kit-preset-your-preset/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
@@ -242,7 +285,7 @@ git push origin add-your-preset
### Checklist
- [ ] Valid preset.yml manifest
- [ ] README.md with description and usage
- [ ] Usage README with a valid `specify preset add ...` command, linked from `documentation` (preset-scoped README recommended for monorepos)
- [ ] LICENSE file included
- [ ] GitHub release created
- [ ] Preset tested with `specify preset add --dev`
@@ -263,7 +306,15 @@ After submission, maintainers will review:
2. **Template quality** — templates are useful and well-structured
3. **Command coherence** — commands reference sections that exist in templates
4. **Security** — no malicious content, safe file operations
5. **Documentation**clear README explaining what the preset does
5. **Documentation** — the README linked from `documentation` explains how to use *this* preset and contains a valid `specify preset add ...` command
> **Reviewer note:** the workflow can mechanically check *structure* (the linked README
> resolves and contains a valid `specify preset add ...` snippet; when that snippet uses the
> `--from <url>` form, its URL must match the submitted download URL exactly — other accepted
> forms like `specify preset add <id>` don't reference the download URL at all). Whether the
> README genuinely documents *this* preset is partly a content judgment, so a human reviewer
> should still confirm the linked doc isn't just a funnel to a separate product or CLI before
> approving.
Once verified, `verified: true` is set and the preset appears in `specify preset search`.

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-16T00:00:00Z",
"updated_at": "2026-06-25T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -308,11 +308,11 @@
"game-narrative-writing": {
"name": "Game Narrative Writing",
"id": "game-narrative-writing",
"version": "1.0.0",
"description": "Spec-Driven Development for interactive game-narrative pre-production in video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture.",
"version": "1.1.0",
"description": "Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees.",
"author": "Andreas Daumann",
"repository": "https://github.com/adaumann/speckit-preset-game-narrative-writing",
"download_url": "https://github.com/adaumann/speckit-preset-game-narrative-writing/archive/refs/tags/v1.0.0.zip",
"download_url": "https://github.com/adaumann/speckit-preset-game-narrative-writing/releases/download/v1.1.0/v1.1.0-import.zip",
"homepage": "https://github.com/adaumann/speckit-preset-game-narrative-writing",
"documentation": "https://github.com/adaumann/speckit-preset-game-narrative-writing/blob/main/game-narrative-writing/README.md",
"license": "MIT",
@@ -320,27 +320,19 @@
"speckit_version": ">=0.5.0"
},
"provides": {
"templates": 22,
"commands": 36,
"scripts": 2
"templates": 37,
"commands": 34,
"scripts": 5
},
"tags": [
"game-writing",
"interactive-fiction",
"twine",
"ink",
"renpy",
"point-and-click",
"branching-narrative",
"choice-if",
"visual-novel",
"mechanic-hooks",
"game-narrative",
"export",
"series"
"branching",
"twine",
"ink"
],
"created_at": "2026-05-05T08:00:00Z",
"updated_at": "2026-05-05T08:00:00Z"
"updated_at": "2026-06-22T00:00:00Z"
},
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
@@ -572,6 +564,33 @@
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-06-14T00:00:00Z"
},
"sicario-core": {
"name": "SicarioSpec Core",
"id": "sicario-core",
"version": "0.5.1",
"description": "Baseline secure-by-default Spec Kit governance profile.",
"author": "SicarioSpec Contributors",
"repository": "https://github.com/dfirs1car1o/sicario-spec",
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-core-0.5.1.zip",
"homepage": "https://github.com/dfirs1car1o/sicario-spec",
"documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/presets/sicario-core/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.9.0"
},
"provides": {
"templates": 5,
"commands": 0
},
"tags": [
"governance",
"security-ops",
"secure-by-default",
"evidence"
],
"created_at": "2026-06-22T00:00:00Z",
"updated_at": "2026-06-25T00:00:00Z"
},
"spec2cloud": {
"name": "Spec2Cloud",
"id": "spec2cloud",

View File

@@ -1,7 +1,8 @@
[project]
name = "specify-cli"
version = "0.11.4"
version = "0.11.9"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"typer>=0.24.0",
@@ -73,3 +74,13 @@ precision = 2
show_missing = true
skip_covered = false
[tool.ruff.lint]
# Lock in subprocess security posture: any reintroduction of shell=True
# (or os.system / popen2) must be acknowledged with an explicit `# noqa`
# pointing at the rule, making the deviation visible in review.
extend-select = [
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
"S605", # start-process-with-a-shell
]

View File

@@ -83,24 +83,24 @@ if ($PathsOnly) {
# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
[Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)")
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
Write-Output "Run $specifyCommand first to create the feature structure."
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
Write-Output "Run $planCommand first to create the implementation plan."
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
exit 1
}
# Check for tasks.md if required
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
[Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
$tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT
Write-Output "Run $tasksCommand first to create the task list."
[Console]::Error.WriteLine("Run $tasksCommand first to create the task list.")
exit 1
}

View File

@@ -111,8 +111,11 @@ function Get-BranchName {
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -match "\b$($word.ToUpper())\b") {
# Keep short words if they appear as uppercase in original (likely acronyms)
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
# Keep short words only if they appear as uppercase in original (likely
# acronyms). Use -cmatch so the comparison is case-sensitive, matching the
# bash script's case-sensitive grep; -match would be case-insensitive and
# would keep every short word.
$meaningfulWords += $word
}
}

View File

@@ -318,6 +318,12 @@ No implementation code shall be written before:
This completely inverts traditional AI code generation. Instead of generating code and hoping it works, the LLM must first generate comprehensive tests that define behavior, get them approved, and only then generate implementation.
#### Articles IV, V & VI: Project-Defined Governance
Articles IV, V, and VI are intentionally defined by each project's constitution rather than prescribed by Spec Kit. The constitution template provides placeholder slots and example concerns such as integration testing, observability, versioning, and breaking changes, but teams replace those placeholders with the principles that match their system and organization.
This keeps the nine-article structure stable while allowing each project to encode its own non-negotiable standards. For one project, Article IV might govern security and access boundaries; for another, it might define integration test requirements. The `/speckit.analyze` command evaluates the concrete constitution in the project, so these project-defined articles participate in compliance checks just like the built-in examples.
#### Articles VII & VIII: Simplicity and Anti-Abstraction
These paired articles combat over-engineering:

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ through the config-driven helpers in :mod:`specify_cli.authentication.http`.
import os
import urllib.request
from fnmatch import fnmatch
from typing import Callable, Dict, Optional
from urllib.parse import quote, unquote, urlparse
@@ -56,55 +57,79 @@ def build_github_request(url: str) -> urllib.request.Request:
return urllib.request.Request(url, headers=headers)
def _host_matches(hostname: str, patterns: tuple[str, ...]) -> bool:
"""Return True when *hostname* matches a pattern (exact or ``*.suffix``)."""
hostname = hostname.lower()
return any(p == hostname or fnmatch(hostname, p) for p in patterns)
def resolve_github_release_asset_api_url(
download_url: str,
open_url_fn: Callable,
timeout: int = 60,
github_hosts: tuple[str, ...] = (),
) -> Optional[str]:
"""Resolve a GitHub browser release URL to its REST API asset URL.
"""Resolve a GitHub release browser-download URL to its REST API asset URL.
For private or SSO-protected repositories, browser release download
URLs (``https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>``)
redirect to an HTML/SSO page instead of delivering the file. This
helper resolves such a URL to the matching GitHub REST API asset URL
(``https://api.github.com/repos/…/releases/assets/<id>``), which can
then be downloaded with ``Accept: application/octet-stream`` and an
auth token to retrieve the actual file payload.
Works for public ``github.com`` and for GitHub Enterprise Server (GHES)
hosts. A host is treated as GHES when it matches one of *github_hosts*
(exact hostname or ``*.suffix``) — supply the hosts the user has trusted
under a ``github`` provider in ``auth.json``. This allowlist is the
security gate: unlisted hosts never receive GHES API treatment, so a
malicious catalog cannot induce an API request to an arbitrary host.
If *download_url* is already a REST API asset URL, it is returned
as-is. Non-GitHub URLs and GitHub URLs that are not release-download
URLs return ``None``. If the API lookup fails (e.g. network error or
asset not found), ``None`` is returned so callers can fall back to the
original URL.
For a public URL the API base is ``https://api.github.com``; for a GHES
host it is ``{scheme}://{host[:port]}/api/v3``. Returns the API asset URL
(downloadable with ``Accept: application/octet-stream`` + a token), the
input unchanged if it is already an API asset URL, or ``None`` when the
URL is not a resolvable GitHub release download or the lookup fails.
Args:
download_url: The URL to resolve.
open_url_fn: A callable compatible with
``specify_cli.authentication.http.open_url`` used to make the
authenticated API request.
``specify_cli.authentication.http.open_url`` used for the
authenticated release-metadata lookup.
timeout: Per-request timeout in seconds.
Returns:
The resolved REST API asset URL, or ``None`` if resolution is not
applicable or fails.
github_hosts: Host patterns to treat as GitHub Enterprise Server.
"""
import json
import urllib.error
parsed = urlparse(download_url)
hostname = (parsed.hostname or "").lower()
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
# Already a REST API asset URL — use it directly
if (
parsed.hostname == "api.github.com"
and len(parts) >= 6
and parts[:1] == ["repos"]
and parts[3:5] == ["releases", "assets"]
):
is_ghes = (
bool(hostname)
and hostname not in GITHUB_HOSTS
and _host_matches(hostname, github_hosts)
)
def _is_asset_path(segments: list[str]) -> bool:
return (
len(segments) >= 6
and segments[:1] == ["repos"]
and segments[3:5] == ["releases", "assets"]
)
# Already a REST API asset URL — use it directly. Pure passthrough induces
# no new request: the caller fetches this same URL regardless, so it is
# gated on path shape alone rather than the GHES allowlist. The token stays
# independently gated by auth.json in the download helper, and only the
# resolving path below (which issues a tag-lookup request) needs the
# allowlist as its anti-SSRF gate.
if hostname == "api.github.com" and _is_asset_path(parts):
return download_url
if hostname and parts[:2] == ["api", "v3"] and _is_asset_path(parts[2:]):
return download_url
# Only handle github.com browser release download URLs
if parsed.hostname != "github.com":
# Determine the REST API base for browser release-download URLs.
if hostname == "github.com":
api_base = "https://api.github.com"
elif is_ghes:
authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}"
api_base = f"{parsed.scheme}://{authority}/api/v3"
else:
return None
# Expecting /<owner>/<repo>/releases/download/<tag>/<asset>
@@ -114,7 +139,7 @@ def resolve_github_release_asset_api_url(
owner, repo, tag = parts[0], parts[1], parts[4]
asset_name = "/".join(parts[5:])
encoded_tag = quote(tag, safe="")
release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
try:
with open_url_fn(release_url, timeout=timeout) as response:

View File

@@ -8,6 +8,9 @@ and ``specify init``'s next-steps output stay consistent.
from __future__ import annotations
# Agents that render $speckit-<name> (chat invocation) when in skills mode.
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"})
# Agents that always render /speckit-<name>, regardless of ai_skills.
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "trae", "zed"})
@@ -26,6 +29,17 @@ CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
)
def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
"""Return ``True`` if *selected_ai* uses ``$speckit-<name>`` invocations.
Agents in `DOLLAR_SKILLS_AGENTS` (e.g. ``codex``, ``zcode``) render
``$speckit-<name>`` chat invocations when installed in skills mode.
"""
if not isinstance(selected_ai, str):
return False
return selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled
def is_slash_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
"""Return ``True`` if *selected_ai* uses ``/speckit-<name>`` invocations.

View File

@@ -65,14 +65,31 @@ def dump_frontmatter(data: dict[str, Any]) -> str:
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
"""Run a shell command and optionally capture output."""
def run_command(
cmd: list[str],
check_return: bool = True,
capture: bool = False,
shell: bool = False,
) -> str | None:
"""Run a command without invoking a shell and optionally capture output.
The ``shell`` parameter is kept in the signature so existing keyword
callers (and the re-export from ``specify_cli``) don't raise ``TypeError``,
but only the default ``shell=False`` is honoured. ``shell=True`` is
rejected with ``ValueError`` rather than silently ignored, so the
unsupported mode fails loudly instead of running with a different meaning.
"""
if shell:
raise ValueError(
"run_command() does not support shell=True; pass argv as a list"
)
try:
if capture:
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)
return result.stdout.strip()
else:
subprocess.run(cmd, check=check_return, shell=shell)
subprocess.run(cmd, check=check_return)
return None
except subprocess.CalledProcessError as e:
if check_return:

View File

@@ -37,6 +37,8 @@ def _build_agent_configs() -> dict[str, Any]:
# when register_commands() resolves __SPECKIT_COMMAND_*__ tokens.
if "invoke_separator" not in config:
config["invoke_separator"] = integration.invoke_separator
if integration.dev_no_symlink:
config["dev_no_symlink"] = True
configs[key] = config
return configs
@@ -234,9 +236,14 @@ class CommandRegistrar:
toml_lines.append(f"# Source: {source_id}")
toml_lines.append("")
# Keep TOML output valid even when body contains triple-quote delimiters.
# Prefer multiline forms, then fall back to escaped basic string.
if '"""' not in body:
# Keep TOML output valid even when body contains triple-quote delimiters
# or backslashes. Prefer multiline forms, then fall back to escaped basic
# string. A multiline *basic* string ("""...""") processes backslash escape
# sequences, so a body containing a backslash (e.g. a Windows path
# ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would
# produce unparseable TOML — route those to the *literal* form ('''...'''),
# which does not process escapes, or to the escaped basic string.
if '"""' not in body and "\\" not in body:
toml_lines.append('prompt = """')
toml_lines.append(body)
toml_lines.append('"""')
@@ -427,14 +434,34 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
# Resolve __CONTEXT_FILE__ from the agent-context extension config.
# Fall back to init-options.json for projects that haven't migrated.
# When disabled, ignore stale context_files but keep the singular
# context_file value so generated commands still point at the agent
# context file managed before the extension was disabled.
from .integrations.base import IntegrationBase
# Local import: _load_agent_context_config lives in __init__.py which
# imports agents.py, so a top-level import would be circular.
from . import _load_agent_context_config
ac_cfg = _load_agent_context_config(project_root)
context_file = ac_cfg.get("context_file") or ""
if not context_file:
context_file = init_opts.get("context_file") or ""
extension_enabled = IntegrationBase._agent_context_extension_enabled(
project_root
)
if extension_enabled:
context_files = IntegrationBase._resolve_context_file_values(
project_root,
ac_cfg,
legacy_context_file=init_opts.get("context_file"),
)
else:
context_files = IntegrationBase._resolve_context_file_values(
project_root,
ac_cfg,
legacy_context_file=init_opts.get("context_file"),
include_context_files=False,
validate=False,
)
context_file = IntegrationBase._format_context_file_values(context_files)
body = body.replace("__CONTEXT_FILE__", context_file)
return CommandRegistrar.rewrite_project_relative_paths(body)
@@ -694,6 +721,7 @@ class CommandRegistrar:
output_name,
agent_config["extension"],
link_outputs,
agent_config,
)
if agent_name == "copilot":
@@ -768,6 +796,7 @@ class CommandRegistrar:
alias_output_name,
agent_config["extension"],
link_outputs,
agent_config,
)
if agent_name == "copilot":
self.write_copilot_prompt(project_root, alias)
@@ -784,9 +813,12 @@ class CommandRegistrar:
output_name: str,
extension: str,
link_outputs: bool,
agent_config: dict[str, Any] | None = None,
) -> None:
"""Write a rendered agent artifact, optionally as a dev-mode symlink."""
if not link_outputs:
if not link_outputs or (agent_config or {}).get("dev_no_symlink"):
if dest_file.is_symlink():
dest_file.unlink()
dest_file.write_text(content, encoding="utf-8")
return
@@ -907,6 +939,16 @@ class CommandRegistrar:
self._active_skills_agent(project_root)
if create_missing_active_skills_dir else None
)
active_skills_dir: Optional[Path] = None
if active_skills_agent:
active_skills_config = self.AGENT_CONFIGS.get(active_skills_agent)
if (
active_skills_config
and active_skills_config.get("extension") == "/SKILL.md"
):
active_skills_dir = self._resolve_agent_dir(
active_skills_agent, active_skills_config, project_root,
)
active_created_skills_dir: Optional[Path] = None
for agent_name, agent_config in self.AGENT_CONFIGS.items():
active_skills_output = (
@@ -938,6 +980,14 @@ class CommandRegistrar:
agent_dir = self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
shares_active_skills_dir = (
active_skills_dir is not None
and agent_name != active_skills_agent
and agent_config.get("extension") == "/SKILL.md"
and self._same_lexical_path(agent_dir, active_skills_dir)
)
if shares_active_skills_dir:
continue
agent_dir_existed = agent_dir.is_dir()
register_missing_active_skills_agent = (

View File

@@ -118,6 +118,20 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
return urllib.request.Request(url, headers=headers)
def github_provider_hosts() -> tuple[str, ...]:
"""Return host patterns from every ``github`` provider entry in ``auth.json``.
Used to classify which hosts are GitHub Enterprise Server instances when
resolving release-asset download URLs. Returns an empty tuple when no
``auth.json`` exists or it contains no ``github`` entries.
"""
hosts: list[str] = []
for entry in _load_config():
if entry.provider == "github":
hosts.extend(entry.hosts)
return tuple(hosts)
def open_url(
url: str,
timeout: int = 10,

View File

@@ -693,6 +693,7 @@ def register(app: typer.Typer) -> None:
) or getattr(resolved_integration, "_skills_mode", False)
codex_skill_mode = selected_ai == "codex" and _is_skills_integration
zcode_skill_mode = selected_ai == "zcode" and _is_skills_integration
claude_skill_mode = selected_ai == "claude" and _is_skills_integration
kimi_skill_mode = selected_ai == "kimi"
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
@@ -706,6 +707,7 @@ def register(app: typer.Typer) -> None:
cline_skill_mode = selected_ai == "cline"
native_skill_mode = (
codex_skill_mode
or zcode_skill_mode
or claude_skill_mode
or kimi_skill_mode
or agy_skill_mode
@@ -721,6 +723,11 @@ def register(app: typer.Typer) -> None:
f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]"
)
step_num += 1
if zcode_skill_mode:
steps_lines.append(
f"{step_num}. Start ZCode in this project directory; spec-kit skills were installed to [cyan].zcode/skills[/cyan]"
)
step_num += 1
if claude_skill_mode:
steps_lines.append(
f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]"
@@ -743,7 +750,10 @@ def register(app: typer.Typer) -> None:
step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"
from .._invocation_style import is_slash_skills_agent as _is_slash_skills_agent
from .._invocation_style import (
is_dollar_skills_agent as _is_dollar_skills_agent,
is_slash_skills_agent as _is_slash_skills_agent,
)
# `_is_skills_integration` means the integration is installed in
# skills mode, which is the semantic equivalent of `ai_skills_enabled`
@@ -751,7 +761,7 @@ def register(app: typer.Typer) -> None:
_ai_skills_enabled = _is_skills_integration
def _display_cmd(name: str) -> str:
if codex_skill_mode:
if _is_dollar_skills_agent(selected_ai, _ai_skills_enabled):
return f"$speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"

View File

@@ -26,11 +26,12 @@ import yaml
from packaging import version as pkg_version
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from ._init_options import is_ai_skills_enabled
from ._invocation_style import is_slash_skills_agent
from ._utils import dump_frontmatter, relative_extension_path_violation
from .catalogs import CatalogEntry as BaseCatalogEntry
from .catalogs import CatalogStackBase
from .._init_options import is_ai_skills_enabled
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
from .._utils import dump_frontmatter, relative_extension_path_violation
from ..catalogs import CatalogEntry as BaseCatalogEntry
from ..catalogs import CatalogStackBase
from ..shared_infra import verify_archive_sha256
_FALLBACK_CORE_COMMAND_NAMES = frozenset(
{
@@ -905,7 +906,7 @@ class ExtensionManager:
be created due to symlink, containment, or permission issues so
that callers can fall back gracefully.
"""
from . import (
from .. import (
_print_cli_warning,
load_init_options,
resolve_active_skills_dir,
@@ -948,7 +949,7 @@ class ExtensionManager:
if not isinstance(selected_ai, str) or not selected_ai:
return _ensure_usable(skills_dir)
from .agents import CommandRegistrar
from ..agents import CommandRegistrar
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai)
@@ -985,9 +986,9 @@ class ExtensionManager:
if not skills_dir:
return []
from . import load_init_options
from .agents import CommandRegistrar
from .integrations import get_integration
from .. import load_init_options
from ..agents import CommandRegistrar
from ..integrations import get_integration
written: List[str] = []
opts = load_init_options(self.project_root)
@@ -997,6 +998,7 @@ class ExtensionManager:
if not isinstance(selected_ai, str) or not selected_ai:
return []
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
integration = get_integration(selected_ai)
for cmd_info in manifest.commands:
@@ -1030,15 +1032,16 @@ class ExtensionManager:
skill_file = skill_subdir / "SKILL.md"
cache_root = extension_dir / ".specify-dev" / "extension-skills"
cache_file = cache_root / skill_name / "SKILL.md"
use_dev_symlink = link_outputs and not agent_config.get("dev_no_symlink")
CommandRegistrar._ensure_inside(cache_file, cache_root)
if skill_file.exists() or skill_file.is_symlink():
is_expected_dev_symlink = self._is_expected_dev_symlink(
skill_file, cache_file
)
# Do not overwrite user-customized skills, but allow dev-mode
# symlinks that point back to this extension's generated cache
# to be refreshed on a subsequent dev install.
if not (
link_outputs
and self._is_expected_dev_symlink(skill_file, cache_file)
):
if not is_expected_dev_symlink:
continue
# Create skill directory; track whether we created it so we can clean
@@ -1093,7 +1096,7 @@ class ExtensionManager:
):
skill_content = integration.post_process_skill_content(skill_content)
if link_outputs:
if use_dev_symlink:
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(skill_content, encoding="utf-8")
@@ -1106,6 +1109,8 @@ class ExtensionManager:
skill_file.unlink()
skill_file.write_text(skill_content, encoding="utf-8")
else:
if skill_file.is_symlink():
skill_file.unlink()
skill_file.write_text(skill_content, encoding="utf-8")
written.append(skill_name)
@@ -1201,7 +1206,7 @@ class ExtensionManager:
shutil.rmtree(skill_subdir)
else:
# Fallback: scan all possible agent skills directories
from . import AGENT_CONFIG, DEFAULT_SKILLS_DIR
from .. import AGENT_CONFIG, DEFAULT_SKILLS_DIR
candidate_dirs: set[Path] = set()
for cfg in AGENT_CONFIG.values():
@@ -1616,7 +1621,7 @@ class ExtensionManager:
# Resolve the skills directory for the specific agent so cleanup is
# agent-scoped and does not depend on the currently-active agent in
# init-options. Use the same helper that extension install uses.
from . import _get_skills_dir as resolve_skills_dir
from .. import _get_skills_dir as resolve_skills_dir
agent_skills_dir = resolve_skills_dir(self.project_root, agent_name)
@@ -1678,21 +1683,17 @@ class ExtensionManager:
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
"""Register installed, enabled extensions for ``agent_name``.
This is intended to be called after switching integrations. Command
registration is scoped to the explicit ``agent_name`` argument, but some
behavior still depends on the current init-options state (for example,
skills-mode handling uses the active ``ai`` / ``ai_skills`` settings).
Callers should therefore pass the agent that has just been made active
in init-options; in normal use, ``agent_name`` is expected to match the
current ``ai`` value. This mirrors extension install behavior while
avoiding stale default-mode command directories when that active agent
is running in skills mode (notably Copilot ``--skills``).
Command-file registration is scoped to the explicit ``agent_name``
argument, so this method can be used after install, upgrade, or switch.
Extension skill rendering is still scoped to the active ``ai`` /
``ai_skills`` settings in init-options, so non-active skills-mode
targets receive command files here. Per-agent skills parity is tracked
separately in #2948.
"""
if not agent_name:
return
from . import load_init_options
from .. import load_init_options
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
@@ -1744,38 +1745,53 @@ class ExtensionManager:
if new_registered != registered_commands:
updates["registered_commands"] = new_registered
try:
registered_skills = self._register_extension_skills(manifest, ext_dir)
except Exception as skills_err:
# Skills are a companion artifact. If command registration
# already succeeded, still persist it so later cleanup can
# find those command files.
from . import _print_cli_warning
_print_cli_warning(
"register extension skills for",
"extension",
ext_id,
skills_err,
continuing=(
"Continuing with available registration results for this "
"extension and the remaining extensions."
),
)
else:
if registered_skills:
existing_skills = self._valid_name_list(
metadata.get("registered_skills", [])
# Extension *skills* are only ever rendered for the active agent:
# `_register_extension_skills` resolves the skills dir and
# frontmatter from init-options["ai"], ignoring ``agent_name``.
# When this method runs for a non-active agent — as install/upgrade
# now do for a secondary integration (#2886) — the skills pass would
# re-render the *active* agent's extension skills as a side effect,
# resurrecting skill files the user deliberately deleted. Skip it
# unless the target is the active agent; `switch` is unaffected
# because it activates the target before registering. (Rendering
# skills for a non-active target is tracked separately in #2948.)
if agent_name == active_agent:
try:
registered_skills = self._register_extension_skills(
manifest, ext_dir
)
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
updates["registered_skills"] = merged_skills
except Exception as skills_err:
# Skills are a companion artifact. If command registration
# already succeeded, still persist it so later cleanup can
# find those command files.
from .. import _print_cli_warning
_print_cli_warning(
"register extension skills for",
"extension",
ext_id,
skills_err,
continuing=(
"Continuing with available registration results for this "
"extension and the remaining extensions."
),
)
else:
if registered_skills:
existing_skills = self._valid_name_list(
metadata.get("registered_skills", [])
)
merged_skills = list(
dict.fromkeys(existing_skills + registered_skills)
)
updates["registered_skills"] = merged_skills
if updates:
self.registry.update(ext_id, updates)
except Exception as ext_err:
# Best-effort per extension: warn and move on so a single bad
# extension cannot silently drop the others. See #2950.
from . import _print_cli_warning
from .. import _print_cli_warning
_print_cli_warning(
"register extension artifacts for",
@@ -1882,31 +1898,31 @@ class CommandRegistrar:
"""
# Re-export AGENT_CONFIGS at class level for direct attribute access
from .agents import CommandRegistrar as _AgentRegistrar
from ..agents import CommandRegistrar as _AgentRegistrar
AGENT_CONFIGS = _AgentRegistrar.AGENT_CONFIGS
def __init__(self):
from .agents import CommandRegistrar as _Registrar
from ..agents import CommandRegistrar as _Registrar
self._registrar = _Registrar()
# Delegate static/utility methods
@staticmethod
def parse_frontmatter(content: str) -> tuple[dict, str]:
from .agents import CommandRegistrar as _Registrar
from ..agents import CommandRegistrar as _Registrar
return _Registrar.parse_frontmatter(content)
@staticmethod
def render_frontmatter(fm: dict) -> str:
from .agents import CommandRegistrar as _Registrar
from ..agents import CommandRegistrar as _Registrar
return _Registrar.render_frontmatter(fm)
@staticmethod
def _write_copilot_prompt(project_root, cmd_name: str) -> None:
from .agents import CommandRegistrar as _Registrar
from ..agents import CommandRegistrar as _Registrar
_Registrar.write_copilot_prompt(project_root, cmd_name)
@@ -2041,12 +2057,18 @@ class ExtensionCatalog(CatalogStackBase):
) -> Optional[str]:
"""Resolve a GitHub release asset URL to its API asset URL.
Delegates to the shared helper in :mod:`specify_cli._github_http`.
Delegates to the shared helper in :mod:`specify_cli._github_http`,
passing the ``github`` provider hosts from ``auth.json`` so GitHub
Enterprise Server release assets resolve via ``/api/v3``.
"""
from specify_cli._github_http import resolve_github_release_asset_api_url
from specify_cli.authentication.http import github_provider_hosts
return resolve_github_release_asset_api_url(
download_url, self._open_url, timeout=timeout
download_url,
self._open_url,
timeout=timeout,
github_hosts=github_provider_hosts(),
)
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
@@ -2606,6 +2628,10 @@ class ExtensionCatalog(CatalogStackBase):
) as response:
zip_data = response.read()
verify_archive_sha256(
zip_data, ext_info.get("sha256"), extension_id, ExtensionError
)
zip_path.write_bytes(zip_data)
return zip_path
@@ -2857,7 +2883,7 @@ class HookExecutor:
instance to avoid repeated filesystem reads during hook rendering.
"""
if self._init_options_cache is None:
from . import load_init_options
from .. import load_init_options
payload = load_init_options(self.project_root)
self._init_options_cache = payload if isinstance(payload, dict) else {}
@@ -2886,17 +2912,17 @@ class HookExecutor:
selected_ai = init_options.get("ai")
ai_skills_enabled = is_ai_skills_enabled(init_options)
codex_skill_mode = selected_ai == "codex" and ai_skills_enabled
dollar_skill_mode = is_dollar_skills_agent(selected_ai, ai_skills_enabled)
kimi_skill_mode = selected_ai == "kimi"
cline_mode = selected_ai == "cline"
skill_name = self._skill_name_from_command(command_id)
if codex_skill_mode and skill_name:
if dollar_skill_mode and skill_name:
return f"${skill_name}"
if kimi_skill_mode and skill_name:
return f"/skill:{skill_name}"
if cline_mode:
from .integrations.cline import format_cline_command_name
from ..integrations.cline import format_cline_command_name
return f"/{format_cline_command_name(command_id)}"

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,7 @@ def _register_builtins() -> None:
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .devin import DevinIntegration
from .firebender import FirebenderIntegration
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
from .generic import GenericIntegration
@@ -69,6 +70,7 @@ def _register_builtins() -> None:
from .kimi import KimiIntegration
from .kiro_cli import KiroCliIntegration
from .lingma import LingmaIntegration
from .omp import OmpIntegration
from .opencode import OpencodeIntegration
from .pi import PiIntegration
from .qodercli import QodercliIntegration
@@ -80,6 +82,7 @@ def _register_builtins() -> None:
from .trae import TraeIntegration
from .vibe import VibeIntegration
from .windsurf import WindsurfIntegration
from .zcode import ZcodeIntegration
from .zed import ZedIntegration
# -- Registration (alphabetical) --------------------------------------
@@ -94,6 +97,7 @@ def _register_builtins() -> None:
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(DevinIntegration())
_register(FirebenderIntegration())
_register(ForgeIntegration())
_register(GeminiIntegration())
_register(GenericIntegration())
@@ -105,6 +109,7 @@ def _register_builtins() -> None:
_register(KimiIntegration())
_register(KiroCliIntegration())
_register(LingmaIntegration())
_register(OmpIntegration())
_register(OpencodeIntegration())
_register(PiIntegration())
_register(QodercliIntegration())
@@ -116,6 +121,7 @@ def _register_builtins() -> None:
_register(TraeIntegration())
_register(VibeIntegration())
_register(WindsurfIntegration())
_register(ZcodeIntegration())
_register(ZedIntegration())

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from typing import Any, Callable
import typer
@@ -131,7 +131,7 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
if ext_cfg_path.exists():
_update_agent_context_config_file(
project_root, "", preserve_markers=True
project_root, "", preserve_markers=True, preserve_context_files=False
)
elif has_legacy_context_keys:
save_init_options(project_root, opts)
@@ -277,12 +277,14 @@ def _update_init_options_for_integration(
"""Update init-options.json and the agent-context extension config to
reflect *integration* as the active one.
``context_file`` and ``context_markers`` are stored in the agent-context
``context_file``, ``context_files``, and ``context_markers`` are stored in the agent-context
extension config (``.specify/extensions/agent-context/agent-context-config.yml``),
not in ``init-options.json``. Existing user-customised markers are
always preserved when the config already exists; invalid marker values
are silently ignored at runtime by ``_resolve_context_markers()`` which
falls back to the class-level defaults.
always preserved when the config already exists. Existing ``context_files``
lists are also preserved so projects can keep multi-agent context anchors
during integration switches. Invalid marker values are
silently ignored at runtime by ``_resolve_context_markers()`` which falls
back to the class-level defaults.
"""
from .. import (
_AGENT_CTX_EXT_CONFIG,
@@ -385,6 +387,93 @@ def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
raise typer.Exit(1)
# ---------------------------------------------------------------------------
# Extension (un)registration helpers (shared by use / switch / upgrade)
# ---------------------------------------------------------------------------
def _best_effort_extension_op(
project_root: Path,
agent_key: str,
op: Callable[[Any, str], None],
*,
phase: str,
continuing: str,
) -> None:
"""Run a best-effort ``ExtensionManager`` operation for ``agent_key``.
``op`` receives the ``ExtensionManager`` and ``agent_key``. Any failure is
surfaced as a warning via ``_print_cli_warning`` and never aborts the
surrounding integration operation. ``continuing`` describes what already
succeeded so the warning makes the partial outcome clear.
"""
try:
from ..extensions import ExtensionManager
ext_mgr = ExtensionManager(project_root)
op(ext_mgr, agent_key)
except Exception as ext_err:
from .. import _print_cli_warning
_print_cli_warning(phase, "integration", agent_key, ext_err, continuing=continuing)
def _register_extensions_for_agent(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Register all enabled extensions' commands/skills for ``agent_key``.
``use`` / ``switch`` re-register enabled extensions for the agent they
activate; ``upgrade`` backfills them for the refreshed agent. Plain
``install`` deliberately does not call this helper so adding a secondary
integration has no extension side effects until it is selected or upgraded.
See issue #2886.
Known limitation: extension *skill* rendering is scoped to the active
agent (init-options track a single ``ai`` / ``ai_skills`` pair). A
skills-mode agent registered while it is *not* the active agent (e.g.
Copilot ``--skills`` registered while non-active) therefore
receives command files rather than skills here — matching ``extension
add``'s multi-agent behavior. ``use`` / ``switch`` avoid this because they
make the target the active agent first. Per-agent skills parity is tracked in
#2948.
Best-effort: never aborts the surrounding integration operation. Callers
invoke it *after* the use/upgrade/switch transaction has committed so a
failure here cannot trigger a rollback.
"""
_best_effort_extension_op(
project_root,
agent_key,
lambda mgr, key: mgr.register_enabled_extensions_for_agent(key),
phase="register extension artifacts for",
continuing=continuing,
)
def _unregister_extensions_for_agent(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Best-effort removal of ``agent_key``'s extension artifacts.
Used by ``switch`` when uninstalling the previous integration so its
extension command/skill files don't linger as orphans in the old agent's
directory.
"""
_best_effort_extension_op(
project_root,
agent_key,
lambda mgr, key: mgr.unregister_agent_artifacts(key),
phase="clean up extension artifacts for",
continuing=continuing,
)
# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------

View File

@@ -27,12 +27,14 @@ from ._helpers import (
_get_speckit_version,
_read_integration_json,
_refresh_init_options_speckit_version,
_register_extensions_for_agent,
_remove_integration_json,
_resolve_integration_options,
_resolve_integration_script_type,
_resolve_script_type,
_set_default_integration,
_set_default_integration_or_exit,
_unregister_extensions_for_agent,
_update_init_options_for_integration,
_write_integration_json,
)
@@ -120,6 +122,14 @@ def integration_switch(
parsed_options=parsed_options,
refresh_templates_force=force,
)
_register_extensions_for_agent(
project_root,
target,
continuing=(
"The integration switch succeeded, but installed extensions may "
"need re-registration."
),
)
console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].")
raise typer.Exit(0)
@@ -171,19 +181,11 @@ def integration_switch(
# Unregister extension commands for the old agent so they don't
# remain as orphans in the old agent's directory.
try:
from ..extensions import ExtensionManager
ext_mgr = ExtensionManager(project_root)
ext_mgr.unregister_agent_artifacts(installed_key)
except Exception as ext_err:
_print_cli_warning(
"clean up extension artifacts for",
"integration",
installed_key,
ext_err,
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
)
_unregister_extensions_for_agent(
project_root,
installed_key,
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
)
# Clear metadata so a failed Phase 2 doesn't leave stale references
installed_keys = [installed for installed in installed_keys if installed != installed_key]
@@ -270,22 +272,6 @@ def integration_switch(
parsed_options=parsed_options,
)
# Re-register extension commands for the new agent so that
# previously-installed extensions are available in the new integration.
try:
from ..extensions import ExtensionManager
ext_mgr = ExtensionManager(project_root)
ext_mgr.register_enabled_extensions_for_agent(target)
except Exception as ext_err:
_print_cli_warning(
"register extension artifacts for",
"integration",
target,
ext_err,
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
)
except Exception as exc:
# Attempt rollback of any files written by setup
try:
@@ -333,6 +319,15 @@ def integration_switch(
)
raise typer.Exit(1)
# Re-register extension commands for the new agent so previously-installed
# extensions are available in it. Done after the try/except (the switch has
# committed) so this best-effort step can never trigger the rollback above.
_register_extensions_for_agent(
project_root,
target,
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
)
name = (target_integration.config or {}).get("name", target)
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
@@ -496,5 +491,17 @@ def integration_upgrade(
if stale_removed:
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
# Re-register enabled extensions for the upgraded agent so its extension
# commands are (re)created — including agents installed before this
# back-fill existed. Mirrors switch for command registration; see #2886.
# Done after the upgrade has fully settled (Phase 2 included) and outside
# the try/except above so this best-effort step cannot affect upgrade
# success.
_register_extensions_for_agent(
project_root,
key,
continuing="The integration was upgraded, but installed extensions may need re-registration.",
)
name = (integration.config or {}).get("name", key)
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")

View File

@@ -17,6 +17,7 @@ from ..integration_state import (
from ._commands import integration_app, integration_catalog_app
from ._helpers import (
_read_integration_json,
_register_extensions_for_agent,
_resolve_integration_options,
_set_default_integration_or_exit,
)
@@ -242,6 +243,11 @@ def integration_use(
f"[cyan]specify integration use {key} --force[/cyan]."
),
)
_register_extensions_for_agent(
project_root,
key,
continuing="The integration was selected, but installed extensions may need re-registration.",
)
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")

View File

@@ -20,7 +20,7 @@ import shlex
import shutil
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PureWindowsPath
from typing import TYPE_CHECKING, Any
import yaml
@@ -93,6 +93,11 @@ class IntegrationBase(ABC):
* ``context_file`` — path (relative to project root) of the agent
context/instructions file (e.g. ``"CLAUDE.md"``)
Projects may additionally opt into managing multiple context files by
setting ``context_files`` in the agent-context extension config. The
integration class still declares one default ``context_file`` for backwards
compatibility and command-template rendering.
"""
# -- Must be set by every subclass ------------------------------------
@@ -114,6 +119,9 @@ class IntegrationBase(ABC):
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
dev_no_symlink: bool = False
"""Whether dev-mode registration should write files instead of symlinks."""
multi_install_safe: bool = False
"""Whether this integration is declared safe to install alongside others.
@@ -632,6 +640,11 @@ class IntegrationBase(ABC):
return True
return entry.get("enabled", True) is not False
@staticmethod
def _context_file_dedupe_key(path: str) -> str:
"""Return the comparison key for context file de-duplication."""
return path.casefold() if os.name == "nt" else path
def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]:
"""Return the (start, end) context markers to use for *project_root*.
@@ -681,51 +694,156 @@ class IntegrationBase(ABC):
end = cm_end # type: ignore[assignment]
return start, end
def upsert_context_section(
self,
project_root: Path,
plan_path: str = "",
) -> Path | None:
"""Create or update the managed section in the agent context file.
@staticmethod
def _validate_context_file_path(project_root: Path, context_file: str) -> str:
"""Return a safe project-relative context file path.
If the context file does not exist it is created with just the
managed section. If it exists, the content between the configured
start/end markers (default ``<!-- SPECKIT START -->`` /
``<!-- SPECKIT END -->``) is replaced, or appended when no markers
are found. Markers are read from the agent-context extension config
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present, falling back to the class-level constants.
Returns the path to the context file, or ``None`` when
``context_file`` is not set or the ``agent-context`` extension is
disabled.
The agent-context scripts reject paths that can escape the project
root; the Python integration path must apply the same guard before
setup or teardown touches context files.
"""
if not self.context_file:
return None
candidate = context_file.strip()
if not candidate:
raise ValueError("agent-context: context file path must not be empty")
win_path = PureWindowsPath(candidate)
if Path(candidate).is_absolute() or win_path.drive or win_path.root:
raise ValueError(
"agent-context: context files must be project-relative paths; "
f"got {candidate!r}"
)
if "\\" in candidate:
raise ValueError(
"agent-context: context files must not contain backslash "
f"separators; got {candidate!r}"
)
parts = [part for part in re.split(r"[\\/]+", candidate) if part]
if ".." in parts:
raise ValueError(
"agent-context: context files must not contain '..' path "
f"segments; got {candidate!r}"
)
root = project_root.resolve()
target = (root / candidate).resolve(strict=False)
try:
target.relative_to(root)
except ValueError as exc:
raise ValueError(
"agent-context: context file path resolves outside the project "
f"root; got {candidate!r}"
) from exc
return candidate
@classmethod
def _resolve_context_file_values(
cls,
project_root: Path,
cfg: dict[str, Any] | None,
*,
fallback_context_file: Any = None,
legacy_context_file: Any = None,
include_context_files: bool = True,
validate: bool = True,
) -> list[str]:
"""Resolve context file config with shared precedence and de-duplication."""
files: list[str] = []
seen: set[str] = set()
def add_context_file(value: Any) -> None:
if not isinstance(value, str):
return
candidate = value.strip()
if not candidate:
return
if validate:
candidate = cls._validate_context_file_path(project_root, candidate)
key = cls._context_file_dedupe_key(candidate)
if key in seen:
return
files.append(candidate)
seen.add(key)
if isinstance(cfg, dict) and include_context_files:
configured = cfg.get("context_files")
if isinstance(configured, list):
for value in configured:
add_context_file(value)
if files:
return files
if isinstance(cfg, dict):
add_context_file(cfg.get("context_file"))
if files:
return files
add_context_file(fallback_context_file)
if files:
return files
add_context_file(legacy_context_file)
return files
@staticmethod
def _format_context_file_values(context_files: list[str]) -> str:
"""Return context file targets as the template display string."""
return ", ".join(context_files)
def _resolve_context_files(self, project_root: Path) -> list[str]:
"""Return project-relative context files managed for *project_root*.
``context_files`` in the agent-context extension config, when present
and non-empty, takes precedence over the config's singular
``context_file``. The integration class default is used only when the
extension config has no context file target.
Raises ``ValueError`` when a configured path can escape the project
root.
"""
config_path = (
project_root
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
try:
raw = config_path.read_text(encoding="utf-8")
cfg = yaml.safe_load(raw)
except (OSError, UnicodeError, ValueError, yaml.YAMLError):
cfg = None
return self._resolve_context_file_values(
project_root,
cfg,
fallback_context_file=self.context_file,
)
def _context_file_display(self, project_root: Path) -> str:
"""Return human-readable context file target(s) for templates."""
if not self._agent_context_extension_enabled(project_root):
return None
from .. import _load_agent_context_config
from .._console import console # local import to avoid cycles
console.print(
"[yellow]Deprecation:[/yellow] Inline agent-context updates during "
"integration setup will be disabled in v0.12.0. Context file "
"management has moved to the bundled [bold]agent-context[/bold] "
"extension. Run [cyan]specify extension disable agent-context[/cyan] "
"to opt out early.",
highlight=False,
)
marker_start, marker_end = self._resolve_context_markers(project_root)
ctx_path = project_root / self.context_file
section = (
f"{marker_start}\n"
f"{self._build_context_section(plan_path)}\n"
f"{marker_end}\n"
context_files = self._resolve_context_file_values(
project_root,
_load_agent_context_config(project_root),
fallback_context_file=self.context_file,
include_context_files=False,
validate=False,
)
return context_files[0] if context_files else ""
return self._format_context_file_values(
self._resolve_context_files(project_root)
)
@staticmethod
def _upsert_context_file(
ctx_path: Path,
section: str,
marker_start: str,
marker_end: str,
) -> None:
"""Create or update one managed context section."""
if ctx_path.exists():
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
@@ -765,18 +883,70 @@ class IntegrationBase(ABC):
# Ensure .mdc files have required YAML frontmatter
if ctx_path.suffix == ".mdc":
new_content = self._ensure_mdc_frontmatter(new_content)
new_content = IntegrationBase._ensure_mdc_frontmatter(new_content)
else:
ctx_path.parent.mkdir(parents=True, exist_ok=True)
# Cursor .mdc files require YAML frontmatter to be loaded
if ctx_path.suffix == ".mdc":
new_content = self._ensure_mdc_frontmatter(section)
new_content = IntegrationBase._ensure_mdc_frontmatter(section)
else:
new_content = section
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
ctx_path.write_bytes(normalized.encode("utf-8"))
return ctx_path
def upsert_context_section(
self,
project_root: Path,
plan_path: str = "",
) -> Path | None:
"""Create or update the managed section in the agent context file.
If the context file does not exist it is created with just the
managed section. If it exists, the content between the configured
start/end markers (default ``<!-- SPECKIT START -->`` /
``<!-- SPECKIT END -->``) is replaced, or appended when no markers
are found. Markers are read from the agent-context extension config
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present, falling back to the class-level constants.
Returns the path to the first context file, or ``None`` when no context
files are configured or the ``agent-context`` extension is
disabled.
"""
if not self._agent_context_extension_enabled(project_root):
return None
context_files = self._resolve_context_files(project_root)
if not context_files:
return None
from .._console import console # local import to avoid cycles
console.print(
"[yellow]Deprecation:[/yellow] Inline agent-context updates during "
"integration setup will be disabled in v0.12.0. Context file "
"management has moved to the bundled [bold]agent-context[/bold] "
"extension. Run [cyan]specify extension disable agent-context[/cyan] "
"to opt out early.",
highlight=False,
)
marker_start, marker_end = self._resolve_context_markers(project_root)
section = (
f"{marker_start}\n"
f"{self._build_context_section(plan_path)}\n"
f"{marker_end}\n"
)
first_path: Path | None = None
for context_file in context_files:
ctx_path = project_root / context_file
self._upsert_context_file(ctx_path, section, marker_start, marker_end)
if first_path is None:
first_path = ctx_path
return first_path
def remove_context_section(self, project_root: Path) -> bool:
"""Remove the managed section from the agent context file.
@@ -787,68 +957,73 @@ class IntegrationBase(ABC):
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present, falling back to the class-level constants.
"""
if not self.context_file:
return False
if not self._agent_context_extension_enabled(project_root):
return False
ctx_path = project_root / self.context_file
if not ctx_path.exists():
context_files = self._resolve_context_files(project_root)
if not context_files:
return False
marker_start, marker_end = self._resolve_context_markers(project_root)
removed_any = False
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
end_idx = content.find(
marker_end,
start_idx if start_idx != -1 else 0,
)
for context_file in context_files:
ctx_path = project_root / context_file
if not ctx_path.exists():
continue
# Only remove a complete, well-ordered managed section. If either
# marker is missing, leave the file unchanged to avoid deleting
# unrelated user-authored content.
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
return False
removal_start = start_idx
removal_end = end_idx + len(marker_end)
# Consume trailing line ending (CRLF or LF)
if removal_end < len(content) and content[removal_end] == "\r":
removal_end += 1
if removal_end < len(content) and content[removal_end] == "\n":
removal_end += 1
# Also strip a blank line before the section if present
if removal_start > 0 and content[removal_start - 1] == "\n":
if removal_start > 1 and content[removal_start - 2] == "\n":
removal_start -= 1
new_content = content[:removal_start] + content[removal_end:]
# Normalize line endings before comparisons
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
if ctx_path.suffix == ".mdc":
import re
# Delete the file if only YAML frontmatter remains (no body content)
frontmatter_only = re.match(
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
end_idx = content.find(
marker_end,
start_idx if start_idx != -1 else 0,
)
if not normalized.strip() or frontmatter_only:
# Only remove a complete, well-ordered managed section. If either
# marker is missing, leave the file unchanged to avoid deleting
# unrelated user-authored content.
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
continue
removal_start = start_idx
removal_end = end_idx + len(marker_end)
# Consume trailing line ending (CRLF or LF)
if removal_end < len(content) and content[removal_end] == "\r":
removal_end += 1
if removal_end < len(content) and content[removal_end] == "\n":
removal_end += 1
# Also strip a blank line before the section if present
if removal_start > 0 and content[removal_start - 1] == "\n":
if removal_start > 1 and content[removal_start - 2] == "\n":
removal_start -= 1
new_content = content[:removal_start] + content[removal_end:]
# Normalize line endings before comparisons
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
if ctx_path.suffix == ".mdc":
import re
# Delete the file if only YAML frontmatter remains (no body content)
frontmatter_only = re.match(
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
)
if not normalized.strip() or frontmatter_only:
ctx_path.unlink()
removed_any = True
continue
if not normalized.strip():
ctx_path.unlink()
return True
else:
ctx_path.write_bytes(normalized.encode("utf-8"))
removed_any = True
if not normalized.strip():
ctx_path.unlink()
else:
ctx_path.write_bytes(normalized.encode("utf-8"))
return True
return removed_any
@staticmethod
def resolve_command_refs(content: str, separator: str = ".") -> str:
@@ -1119,12 +1294,13 @@ class MarkdownIntegration(IntegrationBase):
else "$ARGUMENTS"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
@@ -1324,13 +1500,14 @@ class TomlIntegration(IntegrationBase):
else "{{args}}"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
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,
context_file=self.context_file or "",
context_file=context_file_display,
)
_, body = self._split_frontmatter(processed)
toml_content = self._render_toml(description, body)
@@ -1519,6 +1696,7 @@ class YamlIntegration(IntegrationBase):
else "{{args}}"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -1534,7 +1712,7 @@ class YamlIntegration(IntegrationBase):
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
)
_, body = self._split_frontmatter(processed)
yaml_content = self._render_yaml(
@@ -1709,6 +1887,7 @@ class SkillsIntegration(IntegrationBase):
else "$ARGUMENTS"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -1732,7 +1911,7 @@ class SkillsIntegration(IntegrationBase):
# Process body through the standard template pipeline
processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.

View File

@@ -22,13 +22,17 @@ ARGUMENT_HINTS: dict[str, str] = {
}
# Per-command frontmatter overrides for skills that should run in a forked
# subagent context. Read-only analysis commands are good candidates: the
# heavy reads (spec/plan/tasks artefacts) collapse to a short summary,
# so isolating them keeps the main conversation context clean.
# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {
"analyze": {"context": "fork", "agent": "general-purpose"},
}
# subagent context. See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent
#
# This is intentionally empty. ``analyze`` was previously forked (added in
# #2511) on the assumption that its heavy reads collapse to a short summary,
# but in practice ``/speckit-analyze`` returns a 300-500 line report that is
# injected back into the main conversation. In long sessions each subsequent
# fork inherits that growing context, compounding overhead until the chat
# freezes (#3185). Until a command genuinely returns a compact result, no
# command opts into ``context: fork``. The injection mechanism below stays in
# place so a future command can be added here when that holds true.
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {}
class ClaudeIntegration(SkillsIntegration):

View File

@@ -27,6 +27,7 @@ class CodexIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
dev_no_symlink = True
multi_install_safe = True
def build_exec_args(

View File

@@ -354,13 +354,14 @@ class CopilotIntegration(IntegrationBase):
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
context_file_display = self._context_file_display(project_root)
# 1. Process and write command files as .agent.md
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(

View File

@@ -0,0 +1,33 @@
"""Firebender IDE integration.
Firebender (https://firebender.com/) is an AI coding agent for Android Studio
and IntelliJ. It reads project-local custom slash commands from
``.firebender/commands/*.mdc`` and project rules from ``.firebender/rules/*.mdc``,
so Spec Kit installs its command templates as ``.mdc`` command files and writes
the managed context section into a ``.firebender/rules/`` rule file.
"""
from ..base import MarkdownIntegration
class FirebenderIntegration(MarkdownIntegration):
key = "firebender"
config = {
"name": "Firebender",
"folder": ".firebender/",
"commands_subdir": "commands",
"install_url": "https://firebender.com/",
"requires_cli": False,
}
registrar_config = {
"dir": ".firebender/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".mdc",
}
context_file = ".firebender/rules/specify-rules.mdc"
multi_install_safe = True
def command_filename(self, template_name: str) -> str:
"""Firebender reads custom slash commands from ``.firebender/commands/*.mdc``."""
return f"speckit.{template_name}.mdc"

View File

@@ -128,13 +128,14 @@ class ForgeIntegration(MarkdownIntegration):
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "{{parameters}}")
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
# Process template with standard MarkdownIntegration logic
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)

View File

@@ -119,12 +119,13 @@ class GenericIntegration(MarkdownIntegration):
script_type = opts.get("script_type", "sh")
arg_placeholder = "$ARGUMENTS"
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(

View File

@@ -114,6 +114,7 @@ class HermesIntegration(SkillsIntegration):
global_skills_dir.mkdir(parents=True, exist_ok=True)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -140,7 +141,7 @@ class HermesIntegration(SkillsIntegration):
self.key,
script_type,
arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.

View File

@@ -1,11 +1,13 @@
"""Kimi Code integration — skills-based agent (Moonshot AI).
Kimi uses the ``.kimi/skills/speckit-<name>/SKILL.md`` layout with
Kimi uses the ``.kimi-code/skills/speckit-<name>/SKILL.md`` layout with
``/skill:speckit-<name>`` invocation syntax.
Includes legacy migration logic for projects initialised before Kimi
moved from dotted skill directories (``speckit.xxx``) to hyphenated
(``speckit-xxx``).
Legacy migration covers projects created before Kimi Code CLI moved to
this layout and handles two distinct changes: the directory move from
``.kimi/`` to ``.kimi-code/`` (including the ``KIMI.md`` → ``AGENTS.md``
context file), and the dotted-to-hyphenated skill naming
(``speckit.xxx`` → ``speckit-xxx``).
"""
from __future__ import annotations
@@ -14,7 +16,7 @@ import shutil
from pathlib import Path
from typing import Any
from ..base import IntegrationOption, SkillsIntegration
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
from ..manifest import IntegrationManifest
@@ -24,19 +26,43 @@ class KimiIntegration(SkillsIntegration):
key = "kimi"
config = {
"name": "Kimi Code",
"folder": ".kimi/",
"folder": ".kimi-code/",
"commands_subdir": "skills",
"install_url": "https://code.kimi.com/",
"requires_cli": True,
}
registrar_config = {
"dir": ".kimi/skills",
"dir": ".kimi-code/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "KIMI.md"
multi_install_safe = True
context_file = "AGENTS.md"
multi_install_safe = False
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Build Kimi's native skill invocation: ``/skill:speckit-<stem>``.
Kimi Code CLI invokes installed skills with a ``/skill:<name>``
slash command (e.g. ``/skill:speckit-plan``), not the bare
``/speckit-<name>`` form produced by the generic skills base
class. Overriding here keeps ``dispatch_command()`` and workflow
command steps aligned with the ``/skill:`` guidance shown at init
time and in rendered hook invocations.
"""
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit.") :]
invocation = "/skill:speckit-" + stem.replace(".", "-")
if args:
invocation = f"{invocation} {args}"
return invocation
def post_process_skill_content(self, content: str) -> str:
"""Ensure in-skill cross-command references use Kimi's `/skill:` syntax."""
content = super().post_process_skill_content(content)
return content.replace("/speckit-", "/skill:speckit-")
@classmethod
def options(cls) -> list[IntegrationOption]:
@@ -51,7 +77,12 @@ class KimiIntegration(SkillsIntegration):
"--migrate-legacy",
is_flag=True,
default=False,
help="Migrate legacy dotted skill dirs (speckit.xxx → speckit-xxx)",
help=(
"Migrate legacy Kimi installations: "
".kimi/skills/ → .kimi-code/skills/, speckit.xxx → speckit-xxx, "
"and (when the agent-context extension is enabled) "
"KIMI.md user content → AGENTS.md"
),
),
]
@@ -62,64 +93,397 @@ class KimiIntegration(SkillsIntegration):
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install skills with optional legacy dotted-name migration."""
"""Install skills with optional legacy migration."""
parsed_options = parsed_options or {}
# Run base setup first so hyphenated targets (speckit-*) exist,
# then migrate/clean legacy dotted dirs without risking user content loss.
# Refuse a symlinked destination before any writes occur. base
# setup() only rejects a destination that *escapes* project_root
# after resolve(), so an in-tree symlinked ``.kimi-code`` /
# ``.kimi-code/skills`` (e.g. ``-> .``) would still pass that check
# and misdirect the SKILL.md writes into an unintended in-tree
# location (e.g. ``./skills/``). Reject any symlinked destination
# component up front so this never happens.
new_skills_dir = self.skills_dest(project_root)
if _has_symlinked_component(new_skills_dir, project_root):
raise ValueError(
f"Skills destination {new_skills_dir} contains a symlinked "
f"path component; refusing to install into it."
)
# Run base setup first so new-path targets (speckit-*) exist,
# then migrate/clean legacy dirs without risking user content loss.
created = super().setup(
project_root, manifest, parsed_options=parsed_options, **opts
)
if parsed_options.get("migrate_legacy", False):
skills_dir = self.skills_dest(project_root)
if skills_dir.is_dir():
_migrate_legacy_kimi_dotted_skills(skills_dir)
old_skills_dir = project_root / ".kimi" / "skills"
# Validate both endpoints. base setup() already rejects a
# destination that *escapes* the project root, but an in-tree
# symlinked ``.kimi-code``/``.kimi-code/skills`` (e.g. ``-> .``)
# would still misdirect the move; ``_is_safe_legacy_dir`` rejects
# any symlinked component, giving the destination the same
# protection as the source.
if _is_safe_legacy_dir(old_skills_dir, project_root) and (
_is_safe_legacy_dir(new_skills_dir, project_root)
):
_migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir)
# Mirror upsert/remove_context_section: a disabled agent-context
# extension is a full opt-out, so skip the KIMI.md → AGENTS.md
# migration entirely and leave both files untouched.
if self._agent_context_extension_enabled(project_root):
marker_start, marker_end = self._resolve_context_markers(project_root)
_migrate_legacy_kimi_context_file(
project_root, marker_start=marker_start, marker_end=marker_end
)
return created
def teardown(
self,
project_root: Path,
manifest: IntegrationManifest,
*,
force: bool = False,
) -> tuple[list[Path], list[Path]]:
"""Uninstall Kimi skills and remove leftover legacy directories."""
removed, skipped = super().teardown(project_root, manifest, force=force)
def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
"""Migrate legacy Kimi dotted skill dirs (speckit.xxx) to hyphenated format.
old_skills_dir = project_root / ".kimi" / "skills"
if _is_safe_legacy_dir(old_skills_dir, project_root):
legacy_dirs = sorted(
[*old_skills_dir.glob("speckit-*"), *old_skills_dir.glob("speckit.*")]
)
for legacy_dir in legacy_dirs:
if legacy_dir.is_symlink() or not legacy_dir.is_dir():
continue
if _is_speckit_generated_skill(legacy_dir):
try:
shutil.rmtree(legacy_dir)
removed.append(legacy_dir)
except OSError:
skipped.append(legacy_dir)
try:
old_skills_dir.rmdir()
except OSError:
pass
return removed, skipped
def _has_symlinked_component(path: Path, project_root: Path) -> bool:
"""Return ``True`` when *path* escapes *project_root* or any component is a symlink.
Walks the components strictly between *project_root* and *path*
(including the final one) and reports whether any of them is a symlink.
Components that do not exist yet are not symlinks, so this safely handles
a not-yet-created destination. *project_root* itself is trusted and never
checked. A *path* outside *project_root* is treated as unsafe.
"""
try:
relative = path.relative_to(project_root)
except ValueError:
return True
current = project_root
for part in relative.parts:
current = current / part
if current.is_symlink():
return True
return False
def _is_safe_legacy_dir(path: Path, project_root: Path) -> bool:
"""Return ``True`` when *path* is a real directory safely inside *project_root*.
Legacy migration and cleanup ``shutil.move()`` and ``shutil.rmtree()``
directories, so a symlinked ``.kimi``/``.kimi/skills`` (or one reached
through a symlinked parent) must never be followed: doing so could
relocate or delete content living outside the project tree — or operate
on an unrelated in-tree directory (e.g. ``.kimi -> .`` makes
``.kimi/skills`` resolve to ``./skills``).
Checking only the fully-resolved path is insufficient, because a symlink
pointing elsewhere *inside* the project still resolves to a location under
*project_root*. We therefore reject the path when it is not a directory,
when any component between *project_root* and *path* is a symlink
(including the final component), or when the resolved path escapes the
resolved *project_root*.
"""
if not path.is_dir():
return False
# Reject if any path component below project_root is a symlink (or the
# path escapes project_root). We trust project_root itself, so only
# components strictly under it are checked.
if _has_symlinked_component(path, project_root):
return False
try:
resolved = path.resolve()
root = project_root.resolve()
except OSError:
return False
return resolved == root or root in resolved.parents
def _migrate_legacy_kimi_skills_dir(
old_skills_dir: Path, new_skills_dir: Path
) -> tuple[int, int]:
"""Migrate skills from the legacy ``.kimi/skills/`` directory to ``.kimi-code/skills/``.
Handles both hyphenated (``speckit-xxx``) and dotted (``speckit.xxx``)
legacy directory names. If a target already exists, the legacy dir is
only removed when its ``SKILL.md`` is byte-identical and no extra user
files are present.
Returns ``(migrated_count, removed_count)``.
"""
if not skills_dir.is_dir():
if not old_skills_dir.is_dir():
return (0, 0)
migrated_count = 0
removed_count = 0
for legacy_dir in sorted(skills_dir.glob("speckit.*")):
if not legacy_dir.is_dir():
# Process hyphenated dirs first, then dotted dirs.
legacy_dirs = sorted(old_skills_dir.glob("speckit-*")) + sorted(
old_skills_dir.glob("speckit.*")
)
for legacy_dir in legacy_dirs:
if legacy_dir.is_symlink() or not legacy_dir.is_dir():
continue
if not (legacy_dir / "SKILL.md").exists():
legacy_skill = legacy_dir / "SKILL.md"
# Treat a symlinked SKILL.md as invalid: later read_bytes() would
# otherwise follow it and read content from outside the project.
if legacy_skill.is_symlink() or not legacy_skill.is_file():
continue
suffix = legacy_dir.name[len("speckit."):]
if not suffix:
target_name = _legacy_to_target_name(legacy_dir.name)
if not target_name:
continue
target_dir = skills_dir / f"speckit-{suffix.replace('.', '-')}"
target_dir = new_skills_dir / target_name
# Skip if the legacy dir is already the target dir (same-directory call).
if legacy_dir.resolve() == target_dir.resolve():
continue
if not target_dir.exists():
target_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(legacy_dir), str(target_dir))
migrated_count += 1
continue
# Target exists — only remove legacy if SKILL.md is identical
# Target exists — only remove legacy if SKILL.md is identical.
# Skip when the target dir or its SKILL.md is a symlink (or the dir is
# not a real directory) so the byte comparison never follows a link
# outside the project. (legacy_skill is already guaranteed to be a real
# file by the guard above.)
if target_dir.is_symlink() or not target_dir.is_dir():
continue
target_skill = target_dir / "SKILL.md"
legacy_skill = legacy_dir / "SKILL.md"
if target_skill.is_file():
try:
if target_skill.read_bytes() == legacy_skill.read_bytes():
has_extra = any(
child.name != "SKILL.md" for child in legacy_dir.iterdir()
)
if not has_extra:
shutil.rmtree(legacy_dir)
removed_count += 1
except OSError:
pass
if target_skill.is_symlink() or not target_skill.is_file():
continue
try:
if target_skill.read_bytes() == legacy_skill.read_bytes():
has_extra = any(
child.name != "SKILL.md" for child in legacy_dir.iterdir()
)
if not has_extra:
shutil.rmtree(legacy_dir)
removed_count += 1
except OSError:
pass
# Remove the legacy skills directory if it is now empty.
try:
old_skills_dir.rmdir()
except OSError:
pass
return (migrated_count, removed_count)
def _legacy_to_target_name(legacy_name: str) -> str:
"""Convert a legacy skill directory name to the modern hyphenated form."""
if legacy_name.startswith("speckit-"):
return legacy_name
if legacy_name.startswith("speckit."):
suffix = legacy_name[len("speckit.") :]
if suffix:
return f"speckit-{suffix.replace('.', '-')}"
return ""
def _is_speckit_generated_skill(skill_dir: Path) -> bool:
"""Return True when *skill_dir* contains a Speckit-generated SKILL.md.
Uses the ``metadata.author`` and ``metadata.source`` fields written by
``SkillsIntegration.setup()`` to avoid deleting user-authored skills.
"""
skill_file = skill_dir / "SKILL.md"
# A symlinked SKILL.md is never treated as Speckit-generated, so teardown
# cleanup never follows it to read frontmatter from outside the project.
if skill_file.is_symlink() or not skill_file.is_file():
return False
try:
content = skill_file.read_text(encoding="utf-8")
except OSError:
return False
if not content.startswith("---"):
return False
parts = content.split("---", 2)
if len(parts) < 3:
return False
try:
import yaml
frontmatter = yaml.safe_load(parts[1])
except Exception:
return False
if not isinstance(frontmatter, dict):
return False
metadata = frontmatter.get("metadata", {})
if not isinstance(metadata, dict):
return False
author = metadata.get("author", "")
source = metadata.get("source", "")
return (
author == "github-spec-kit"
and isinstance(source, str)
and source.startswith("templates/commands/")
)
def _migrate_legacy_kimi_context_file(
project_root: Path,
*,
marker_start: str = IntegrationBase.CONTEXT_MARKER_START,
marker_end: str = IntegrationBase.CONTEXT_MARKER_END,
) -> bool:
"""Migrate user content from legacy ``KIMI.md`` to ``AGENTS.md``.
The Speckit managed section is stripped from ``KIMI.md`` before the
remaining content is appended to ``AGENTS.md``. The legacy file is
deleted if it becomes empty. Returns ``True`` if ``KIMI.md`` was
migrated, ``False`` when the migration is skipped.
The migration is skipped (leaving ``KIMI.md`` untouched) in any of these
cases, so a best-effort legacy cleanup never aborts ``setup()`` or
corrupts ``AGENTS.md``:
- ``KIMI.md`` is a symlink, missing, or unreadable (its target could be
read from outside the project, or it may not be valid UTF-8).
- ``AGENTS.md`` is a symlink (it could redirect the write to a file
outside the project root), exists as a non-file (e.g. a directory),
or is unreadable/unwritable.
- ``KIMI.md`` has a corrupted managed section — only one marker is
present, or the end marker precedes the start. Stripping is only done
when both markers are present and well-ordered, so a partial managed
block is never copied into ``AGENTS.md``; the user repairs it manually.
"""
legacy_path = project_root / "KIMI.md"
if legacy_path.is_symlink() or not legacy_path.is_file():
return False
target_path = project_root / "AGENTS.md"
# Never follow a symlinked target, and never treat an existing non-file
# (e.g. a directory) as a writable context file.
if target_path.is_symlink() or (target_path.exists() and not target_path.is_file()):
return False
try:
content = legacy_path.read_text(encoding="utf-8-sig")
except (OSError, UnicodeDecodeError):
return False
marker_pairs = [(marker_start, marker_end)]
default_pair = (
IntegrationBase.CONTEXT_MARKER_START,
IntegrationBase.CONTEXT_MARKER_END,
)
if default_pair not in marker_pairs:
marker_pairs.append(default_pair)
start_idx = -1
end_idx = -1
has_start = False
has_end = False
for s, e in marker_pairs:
s_idx = content.find(s)
e_idx = content.find(e, s_idx if s_idx != -1 else 0)
has_s = s_idx != -1
has_e = e_idx != -1
if not has_s and not has_e:
continue
# Refuse to migrate a corrupted managed section: exactly one marker, or
# an end marker that does not follow the start.
if has_s != has_e or e_idx <= s_idx:
return False
marker_start, marker_end = s, e
start_idx, end_idx = s_idx, e_idx
has_start = True
has_end = True
break
if has_start and has_end:
removal_start = start_idx
removal_end = end_idx + len(marker_end)
if removal_end < len(content) and content[removal_end] == "\r":
removal_end += 1
if removal_end < len(content) and content[removal_end] == "\n":
removal_end += 1
if removal_start > 0 and content[removal_start - 1] == "\n":
if removal_start > 1 and content[removal_start - 2] == "\n":
removal_start -= 1
content = content[:removal_start] + content[removal_end:]
user_content = content.replace("\r\n", "\n").replace("\r", "\n").strip()
if not user_content:
legacy_path.unlink()
return True
try:
if target_path.is_file():
existing = target_path.read_text(encoding="utf-8-sig")
existing = existing.replace("\r\n", "\n").replace("\r", "\n")
if not existing.endswith("\n"):
existing += "\n"
new_content = existing + "\n" + user_content + "\n"
else:
new_content = user_content + "\n"
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(new_content.encode("utf-8"))
except (OSError, UnicodeDecodeError):
return False
legacy_path.unlink()
return True
def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]:
"""Compatibility shim — migrate legacy dotted skill dirs in place.
.. deprecated::
Kept for direct callers/tests. New code should call
``_migrate_legacy_kimi_skills_dir`` directly.
Delegates to ``_migrate_legacy_kimi_skills_dir`` with *skills_dir* as both
source and destination, so it processes every ``speckit-*`` and
``speckit.*`` entry under *skills_dir*. Because the two paths are
identical, the same-path short-circuit there skips any directory whose
target resolves to itself; in practice this renames dotted
``speckit.xxx`` dirs to hyphenated ``speckit-xxx`` in place and never
moves content outside *skills_dir*.
Returns ``(migrated_count, removed_count)``.
"""
return _migrate_legacy_kimi_skills_dir(skills_dir, skills_dir)

View File

@@ -232,6 +232,30 @@ class IntegrationManifest:
# transition. ``discard`` is a no-op when the key is absent.
self._recovered_files.discard(normalized)
def remove(self, rel_path: str | Path) -> bool:
"""Drop *rel_path* from the tracked file set and any recovered marker.
Operates purely on the manifest's recorded key; it does NOT touch the
file on disk. Returns ``True`` if an entry was present and removed.
Used to keep the manifest consistent after a caller deletes a stale
managed file that the current install no longer ships.
Input is normalized through the same lexical pipeline as
``record_existing`` / ``is_recovered``: absolute paths and paths
containing ``..`` segments are rejected (return ``False``) — such paths
can never be canonical manifest keys, so there is nothing to remove.
"""
rel = Path(rel_path)
if rel.is_absolute() or ".." in rel.parts:
return False
try:
abs_path = _validate_rel_path(rel, self.project_root)
normalized = abs_path.relative_to(self.project_root).as_posix()
except ValueError:
return False
self._recovered_files.discard(normalized)
return self._files.pop(normalized, None) is not None
# -- Querying ---------------------------------------------------------
@property

View File

@@ -0,0 +1,45 @@
"""Oh My Pi (omp) coding agent integration."""
from __future__ import annotations
from ..base import MarkdownIntegration
class OmpIntegration(MarkdownIntegration):
key = "omp"
config = {
"name": "Oh My Pi",
"folder": ".omp/",
"commands_subdir": "commands",
"install_url": "https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent",
"requires_cli": True,
}
registrar_config = {
"dir": ".omp/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
# Diverges from MarkdownIntegration.build_exec_args because OMP's
# CLI parser treats `-p`/`--print` as a boolean (one-shot mode) and
# consumes the prompt as a positional argument — see args.ts in
# can1357/oh-my-pi. JSON output is selected via `--mode json`.
if not self.config or not self.config.get("requires_cli"):
return None
args = [self._resolve_executable(), "--print"]
self._apply_extra_args_env_var(args)
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--mode", "json"])
args.append(prompt)
return args

View File

@@ -0,0 +1,43 @@
"""ZCode integration — skills-based agent (Z.AI).
ZCode uses the ``.zcode/skills/speckit-<name>/SKILL.md`` layout, matching
the Claude Code skill format. Skills are invoked in chat with
``$speckit-<name>``. Z.AI recommends skills (over simple ``/`` commands)
for template- and script-driven workflows such as spec-kit.
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class ZcodeIntegration(SkillsIntegration):
"""Integration for ZCode CLI (Z.AI)."""
key = "zcode"
config = {
"name": "ZCode",
"folder": ".zcode/",
"commands_subdir": "skills",
"install_url": "https://zcode.z.ai/",
"requires_cli": True,
}
registrar_config = {
"dir": ".zcode/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "ZCODE.md"
multi_install_safe = True
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for ZCode)",
),
]

View File

@@ -31,6 +31,7 @@ from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priorit
from .._init_options import is_ai_skills_enabled
from ..integrations.base import IntegrationBase
from .._utils import dump_frontmatter
from ..shared_infra import verify_archive_sha256
def _substitute_core_template(
@@ -1891,10 +1892,19 @@ class PresetCatalog:
download_url: str,
timeout: int = 60,
) -> Optional[str]:
"""Resolve a GitHub release asset URL to its REST API asset URL."""
"""Resolve a GitHub release asset URL to its REST API asset URL.
Passes the ``github`` provider hosts from ``auth.json`` so GitHub
Enterprise Server release assets resolve via ``/api/v3``.
"""
from specify_cli._github_http import resolve_github_release_asset_api_url
from specify_cli.authentication.http import github_provider_hosts
return resolve_github_release_asset_api_url(
download_url, self._open_url, timeout=timeout
download_url,
self._open_url,
timeout=timeout,
github_hosts=github_provider_hosts(),
)
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
@@ -2505,6 +2515,10 @@ class PresetCatalog:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
zip_data = response.read()
verify_archive_sha256(
zip_data, pack_info.get("sha256"), pack_id, PresetError
)
zip_path.write_bytes(zip_data)
return zip_path
@@ -2707,7 +2721,7 @@ class PresetResolver:
# (source-checkout / editable install). This is the canonical home for
# speckit's built-in command/template files and must always be checked
# so that strategy:wrap presets can locate {CORE_TEMPLATE}.
from specify_cli import _locate_core_pack # local import to avoid cycles
from specify_cli import _locate_core_pack, _repo_root # local import to avoid cycles
_core_pack = _locate_core_pack()
if _core_pack is not None:
# Wheel install path
@@ -2727,7 +2741,7 @@ class PresetResolver:
return candidate
else:
# Source-checkout / editable install: templates live at repo root
repo_root = Path(__file__).parent.parent.parent
repo_root = _repo_root()
if template_type == "template":
candidate = repo_root / "templates" / f"{template_name}.md"
elif template_type == "command":
@@ -3079,7 +3093,7 @@ class PresetResolver:
``.specify/templates/`` doesn't contain the core file.
"""
try:
from specify_cli import _locate_core_pack
from specify_cli import _locate_core_pack, _repo_root
except ImportError:
return None
@@ -3102,7 +3116,7 @@ class PresetResolver:
if c.exists():
return c
else:
repo_root = Path(__file__).parent.parent.parent
repo_root = _repo_root()
for name in names:
if template_type == "template":
c = repo_root / "templates" / f"{name}.md"

View File

@@ -144,10 +144,13 @@ def preset_add(
zip_path = Path(tmpdir) / "preset.zip"
try:
from specify_cli.authentication.http import open_url as _open_url
from specify_cli.authentication.http import github_provider_hosts
from specify_cli._github_http import resolve_github_release_asset_api_url
_preset_extra_headers = None
_resolved_from_url = resolve_github_release_asset_api_url(from_url, _open_url)
_resolved_from_url = resolve_github_release_asset_api_url(
from_url, _open_url, github_hosts=github_provider_hosts()
)
if _resolved_from_url:
from_url = _resolved_from_url
_preset_extra_headers = {"Accept": "application/octet-stream"}

View File

@@ -2,6 +2,9 @@
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import re
import tempfile
@@ -11,6 +14,74 @@ from typing import Any
from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest
logger = logging.getLogger(__name__)
# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal
# characters. Callers lowercase the declared value before matching (see
# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and
# normalized rather than rejected.
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
def verify_archive_sha256(
data: bytes,
expected: str | None,
name: str,
error_cls: type[Exception],
) -> None:
"""Verify downloaded archive bytes against a catalog-declared SHA-256.
Catalog entries may pin the expected digest of their release archive in a
``sha256`` field (optionally prefixed with ``"sha256:"``). When present, the
downloaded bytes must match before they are written to disk and installed,
so a corrupted or tampered archive is rejected even though the transport was
HTTPS. Entries without a declared digest are accepted unchanged, keeping the
check backwards compatible.
Args:
data: The raw downloaded archive bytes.
expected: The catalog-declared SHA-256 hex digest, or ``None``.
name: The extension/preset id, used in the error message.
error_cls: Exception type to raise on mismatch (e.g. ``ExtensionError``).
Raises:
error_cls: If ``expected`` is provided and is not a well-formed
SHA-256 hex digest, or does not match ``data``.
"""
# Skip only when no digest is declared at all (``None``). A declared but
# empty/blank value (e.g. ``sha256: ""``) is an authoring error, not an
# opt-out: let it fall through to the format check below so it is rejected
# rather than silently disabling verification.
if expected is None:
logger.debug(
"No sha256 declared for %r; archive integrity was not verified.",
name,
)
return
# Strip *only* a literal ``sha256:`` algorithm prefix (case-insensitive).
# Any other prefix is part of the value and must not be silently dropped,
# otherwise a malformed or wrong-algorithm digest (e.g. ``md5:...``) would
# be quietly accepted as if it were a valid SHA-256.
raw = str(expected).strip()
if raw[:7].lower() == "sha256:":
raw = raw[7:].strip()
expected_hex = raw.lower()
if not _SHA256_HEX_RE.match(expected_hex):
raise error_cls(
f"Invalid sha256 declared for {name!r}: expected 64 hexadecimal "
f"characters (optionally prefixed with 'sha256:'), got "
f"{expected!r}."
)
actual_hex = hashlib.sha256(data).hexdigest()
# Constant-time comparison: both sides are fixed-length hex digests, so use
# ``hmac.compare_digest`` to avoid leaking information through timing.
if not hmac.compare_digest(actual_hex, expected_hex):
raise error_cls(
f"Integrity check failed for {name!r}: the catalog declares "
f"sha256 {expected_hex}, but the downloaded archive is "
f"{actual_hex}. The archive may be corrupted or tampered with."
)
class SymlinkedSharedPathError(ValueError):
"""Raised when a shared infrastructure path or ancestor is a symlink.
@@ -304,7 +375,7 @@ def install_shared_infra(
customization warning to tell the user which flag would overwrite their
customizations.
"""
from .integrations.manifest import _sha256
from .integrations.manifest import _sha256, _validate_rel_path
manifest = load_speckit_manifest(project_path, version=version, console=console)
prior_hashes = dict(manifest.files)
@@ -325,6 +396,11 @@ def install_shared_infra(
symlinked_files: list[str] = []
planned_copies: list[tuple[Path, str, bytes, int]] = []
planned_templates: list[tuple[Path, str, str]] = []
# Track every shared path the current bundle produces so we can detect
# manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set()
scripts_scanned = False
variant_dir = "bash" if script_type == "sh" else "powershell"
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
@@ -379,7 +455,6 @@ def install_shared_infra(
if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
if _ensure_or_bucket_dir(dest_scripts):
variant_dir = "bash" if script_type == "sh" else "powershell"
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir
@@ -387,10 +462,18 @@ def install_shared_infra(
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
# Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant keeps this False, so
# stale-cleanup is skipped — otherwise it would treat every
# tracked script as obsolete and delete it. (The safety
# hinge is this flag, not ``seen_rels``, which also holds
# template paths populated later.)
scripts_scanned = True
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
rel = dst_path.relative_to(project_path).as_posix()
seen_rels.add(rel)
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
continue
write, bucket = _decide_overwrite(rel, dst_path)
@@ -442,6 +525,7 @@ def install_shared_infra(
dst = dest_templates / src.name
rel = dst.relative_to(project_path).as_posix()
seen_rels.add(rel)
if not _safe_dest_or_bucket(dst, rel):
continue
write, bucket = _decide_overwrite(rel, dst)
@@ -521,5 +605,63 @@ def install_shared_infra(
if refresh_hint:
console.print(refresh_hint)
# Remove stale managed scripts: paths a previous install recorded that the
# current core no longer ships — e.g. the legacy
# ``scripts/<variant>/update-agent-context.sh`` superseded by the bundled
# agent-context extension. Left behind, such an orphan can crash when it
# sources a refreshed ``common.sh`` (#3076). Only run when the script source
# was actually scanned (so a missing/empty source never triggers mass
# deletion), scoped to the active variant, and only for *managed* copies —
# a user-customized file (hash diverges), a symlink, or a recovered entry is
# preserved by ``_is_managed``.
if scripts_scanned:
stale_removed: list[str] = []
script_prefix = f".specify/scripts/{variant_dir}/"
for rel in list(prior_hashes):
if rel in seen_rels or not rel.startswith(script_prefix):
continue
# Guard corrupted/hand-edited manifest keys BEFORE any filesystem
# access: absolute, ``..``, or (on Windows) drive-relative keys such
# as ``C:tmp`` are not ``is_absolute()`` yet discard the project root
# when joined. The lexical check is a fast reject; ``_validate_rel_path``
# resolves the join and confirms containment, catching the rest. A key
# that still escapes is *skipped*, never turned into an install-time
# hard failure. Mirrors IntegrationManifest.is_recovered / remove.
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
continue
try:
_validate_rel_path(rel_path, project_path)
except ValueError:
continue
dst = project_path / rel_path
# Already gone from disk but still tracked: drop the orphaned manifest
# entry so the manifest stays consistent (nothing to unlink).
if not dst.exists() and not dst.is_symlink():
manifest.remove(rel)
continue
if not _is_managed(rel, dst):
continue # user-modified / symlink / recovered → preserve
# Never unlink through a symlinked ancestor (writes/deletes could
# escape the project root). The safe-destination check buckets such
# paths under ``symlinked_files`` and we leave them in place.
if not _safe_dest_or_bucket(dst, rel):
continue
try:
dst.unlink()
except OSError as exc:
console.print(f"[yellow]⚠[/yellow] could not remove stale {rel}: {exc}")
continue
manifest.remove(rel)
stale_removed.append(rel)
if stale_removed:
console.print(
f"[yellow]⚠[/yellow] Removed {len(stale_removed)} obsolete shared "
"script(s) left by a previous install:"
)
for path in stale_removed:
console.print(f" {path}")
manifest.save()
return True

View File

@@ -52,9 +52,18 @@ class WorkflowDefinition:
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", {})
# Advisory pre-conditions (spec-kit version / integrations a workflow
# expects). Validated by ``validate_workflow`` (recognized keys only;
# see ``_RECOGNIZED_REQUIRES_KEYS``) but NOT enforced at run time — they
# are not a security boundary. In particular there is no
# ``requires.permissions`` capability gate: shell steps always run with
# the user's privileges.
#
# Holds the raw parsed value, so before ``validate_workflow`` runs it may
# be a non-mapping (``None`` for a bare ``requires:``, a list for
# ``requires: []``, etc.); typed ``Any`` rather than ``dict[str, Any]``
# to avoid implying it is always a mapping at this point.
self.requires: Any = data.get("requires", {})
# Inputs
self.inputs: dict[str, Any] = data.get("inputs", {})
@@ -87,6 +96,15 @@ class WorkflowDefinition:
# ID format: lowercase alphanumeric with hyphens
_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")
# Keys accepted under a workflow's ``requires`` block: the advisory
# pre-conditions documented for workflows (``speckit_version`` and
# ``integrations``). This is the *workflow* schema only — the bundle manifest's
# ``requires`` (see ``bundler/models/manifest.py``) is a separate schema that
# also carries ``tools``/``mcp``; those are not workflow ``requires`` keys.
# Any other key — notably ``permissions`` — is rejected by ``validate_workflow``
# so it is never mistaken for an enforced runtime control.
_RECOGNIZED_REQUIRES_KEYS = frozenset({"speckit_version", "integrations"})
# 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."""
@@ -177,6 +195,36 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"Input {input_name!r} has invalid default: {exc}"
)
# -- Requires ---------------------------------------------------------
# ``requires`` declares advisory pre-conditions (the spec-kit version and
# integrations a workflow expects). Only a fixed set of keys is recognized;
# reject anything else so authoring typos surface here instead of being
# silently ignored at runtime. In particular ``requires.permissions`` is
# rejected explicitly: it reads like a runtime capability gate, but no such
# gate exists — a ``shell`` step always runs with the user's privileges, so
# declaring it would give a false sense of sandboxing.
#
# Mirror ``inputs`` validation: an omitted block defaults to ``{}`` and is
# valid, but any present-but-non-mapping value — ``requires:`` (YAML null),
# ``requires: []`` or ``requires: ''`` — is an authoring error and must
# surface here rather than be silently ignored at runtime.
if not isinstance(definition.requires, dict):
errors.append("'requires' must be a mapping (or omitted).")
else:
for key in definition.requires:
if key == "permissions":
errors.append(
"'requires.permissions' is not a recognized or "
"enforced capability gate — shell steps always run "
"with the user's privileges. Remove it and gate "
"sensitive steps with a 'gate' step instead."
)
elif key not in _RECOGNIZED_REQUIRES_KEYS:
errors.append(
f"Unknown 'requires' key {key!r}. Recognized keys: "
f"{', '.join(sorted(_RECOGNIZED_REQUIRES_KEYS))}."
)
# -- Steps ------------------------------------------------------------
if not isinstance(definition.steps, list):
errors.append("'steps' must be a list.")

View File

@@ -146,6 +146,40 @@ def _build_namespace(context: Any) -> dict[str, Any]:
return ns
def _split_top_level_commas(text: str) -> list[str]:
"""Split *text* on commas that are not inside quotes or nested brackets.
Used for list-literal elements so a quoted element containing a comma
(e.g. ``["a, b", "c"]``) is not split mid-string, and nested lists/calls
(e.g. ``[[1, 2], 3]``) are kept intact.
"""
parts: list[str] = []
buf: list[str] = []
quote: str | None = None
depth = 0
for ch in text:
if quote is not None:
buf.append(ch)
if ch == quote:
quote = None
elif ch in ("'", '"'):
quote = ch
buf.append(ch)
elif ch in "([{":
depth += 1
buf.append(ch)
elif ch in ")]}":
depth = max(0, depth - 1)
buf.append(ch)
elif ch == "," and depth == 0:
parts.append("".join(buf))
buf = []
else:
buf.append(ch)
parts.append("".join(buf))
return parts
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
"""Evaluate a simple expression against the namespace.
@@ -291,7 +325,10 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
inner = expr[1:-1].strip()
if not inner:
return []
items = [_evaluate_simple_expression(i.strip(), namespace) for i in inner.split(",")]
items = [
_evaluate_simple_expression(i.strip(), namespace)
for i in _split_top_level_commas(inner)
]
return items
# Variable reference (dot-path)

View File

@@ -31,7 +31,7 @@ class ShellStep(StepBase):
# control commands; catalog-installed workflows should be reviewed
# before use (see PUBLISHING.md for security guidance).
try:
proc = subprocess.run(
proc = subprocess.run( # noqa: S602 -- intentional shell=True (see NOTE above)
run_cmd,
shell=True,
capture_output=True,

View File

@@ -45,6 +45,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Goal.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Goal
@@ -228,6 +229,7 @@ After reporting, check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Operating Principles

View File

@@ -66,6 +66,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Execution Steps.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Execution Steps
@@ -363,4 +364,5 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -49,6 +49,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -251,6 +252,7 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks

View File

@@ -46,6 +46,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -147,4 +148,5 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -49,6 +49,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Goal.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
@@ -266,5 +267,6 @@ After producing the result, check if `.specify/extensions.yml` exists in the pro
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -45,6 +45,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -192,6 +193,7 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks

View File

@@ -53,6 +53,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -91,6 +92,7 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks

View File

@@ -50,6 +50,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -253,6 +254,7 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks

View File

@@ -54,6 +54,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -111,6 +112,7 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks

View File

@@ -46,6 +46,7 @@ You **MUST** consider the user input before proceeding (if not empty).
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
@@ -100,4 +101,5 @@ Check if `.specify/extensions.yml` exists in the project root.
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently

View File

@@ -298,6 +298,24 @@ class TestCreateFeatureBash:
assert data["BRANCH_NAME"] == "001-user-auth"
assert data["FEATURE_NUM"] == "001"
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
"""A short word is dropped from the derived branch name unless it appears
as an acronym in UPPERCASE in the description (case-sensitive, must match the
PowerShell twin)."""
project = _setup_project(tmp_path)
# lowercase "go" (<3 chars, not an uppercase acronym) is dropped
r1 = _run_bash(
"create-new-feature-branch.sh", project, "--json", "--dry-run", "Add go support",
)
assert r1.returncode == 0, r1.stderr
assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support"
# uppercase "GO" is kept as an acronym
r2 = _run_bash(
"create-new-feature-branch.sh", project, "--json", "--dry-run", "Use GO now",
)
assert r2.returncode == 0, r2.stderr
assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now"
def test_creates_branch_timestamp(self, tmp_path: Path):
"""Extension create-new-feature-branch.sh creates timestamp branch."""
project = _setup_project(tmp_path)
@@ -426,6 +444,21 @@ class TestCreateFeaturePowerShell:
data = json.loads(result.stdout)
assert data["BRANCH_NAME"] == "001-user-auth"
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
"""PowerShell must match the bash twin: a short word is dropped unless it
appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match)."""
project = _setup_project(tmp_path)
r1 = _run_pwsh(
"create-new-feature-branch.ps1", project, "-Json", "-DryRun", "Add go support",
)
assert r1.returncode == 0, r1.stderr
assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support"
r2 = _run_pwsh(
"create-new-feature-branch.ps1", project, "-Json", "-DryRun", "Use GO now",
)
assert r2.returncode == 0, r2.stderr
assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now"
def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path):
"""Branches checked out in sibling worktrees still reserve their prefix."""
project = _setup_project(tmp_path / "project")

View File

@@ -3,8 +3,13 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
from specify_cli import (
@@ -13,18 +18,25 @@ from specify_cli import (
load_init_options,
save_init_options,
)
from specify_cli.agents import CommandRegistrar
from specify_cli.integrations.base import IntegrationBase
from specify_cli.integrations.claude import ClaudeIntegration
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context"
BASH = shutil.which("bash")
POWERSHELL = (
shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell")
)
def _write_ext_config(project_root: Path, **overrides: object) -> None:
"""Write a minimal agent-context extension config."""
cfg: dict = {
"context_file": overrides.get("context_file", ""),
"context_files": overrides.get("context_files", []),
"context_markers": overrides.get(
"context_markers",
{
@@ -72,6 +84,14 @@ class TestExtensionLayout:
assert cmd.is_file()
assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8")
def test_command_file_documents_context_file_constraints(self):
text = (
EXT_DIR / "commands" / "speckit.agent-context.update.md"
).read_text(encoding="utf-8")
assert "context file(s)" in text
assert "Windows drive paths" in text
assert "backslash separators" in text
def test_bundled_scripts_exist(self):
assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file()
assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file()
@@ -107,6 +127,184 @@ class _CtxIntegration(ClaudeIntegration):
"""Use Claude as a concrete integration with a context_file."""
class _NoContextIntegration(IntegrationBase):
"""Minimal integration with no context_file for base-class fallback tests."""
def _install_agent_context_config(project_root: Path, **overrides: object) -> None:
_write_ext_config(project_root, **overrides)
def _bash_posix_path(path: Path) -> str:
"""Convert a Windows path to the POSIX form used by the available bash."""
resolved = str(path.resolve())
if os.name != "nt":
return resolved
if BASH:
converted = subprocess.run(
[
BASH,
"-lc",
"command -v cygpath >/dev/null 2>&1 && cygpath -u \"$1\"",
"bash",
resolved,
],
capture_output=True,
text=True,
timeout=30,
)
if converted.returncode == 0 and converted.stdout.strip():
return converted.stdout.strip()
drive = path.drive.rstrip(":").lower()
posix = path.as_posix()
return f"/mnt/{drive}{posix[2:]}" if drive else posix
def _ensure_test_python_on_path(project_root: Path) -> Path:
"""Create python/python3 shims that run the current pytest interpreter."""
shim_dir = project_root / ".test-python-bin"
shim_dir.mkdir(exist_ok=True)
python_exe = Path(sys.executable).resolve()
shell_python = _bash_posix_path(python_exe)
for name in ("python", "python3"):
shell_shim = shim_dir / name
shell_shim.write_text(
f"#!/usr/bin/env sh\nexec {shlex_quote(shell_python)} \"$@\"\n",
encoding="utf-8",
newline="\n",
)
shell_shim.chmod(0o755)
if os.name == "nt":
cmd_shim = shim_dir / f"{name}.cmd"
cmd_shim.write_text(
f'@echo off\r\n"{python_exe}" %*\r\n',
encoding="utf-8",
)
return shim_dir
def _current_pythonpath() -> str:
"""Return sys.path entries needed by child script interpreters."""
entries = [
entry
for entry in sys.path
if isinstance(entry, str) and entry
]
existing = os.environ.get("PYTHONPATH")
if existing:
entries.extend(entry for entry in existing.split(os.pathsep) if entry)
return os.pathsep.join(dict.fromkeys(entries))
def _bundled_script_env(
project_root: Path,
*,
for_bash: bool = False,
speckit_python: str | None = None,
) -> dict[str, str]:
env = os.environ.copy()
shim_dir = _ensure_test_python_on_path(project_root)
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
env["SPECKIT_PYTHON"] = (
speckit_python
if speckit_python is not None
else (_bash_posix_path(Path(sys.executable)) if for_bash else sys.executable)
)
pythonpath = _current_pythonpath()
if pythonpath:
env["PYTHONPATH"] = pythonpath
return env
def _run_bash_agent_context_script(
project_root: Path,
*,
speckit_python: str | None = None,
) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
env = _bundled_script_env(
project_root,
for_bash=True,
speckit_python=speckit_python,
)
if os.name == "nt":
root = _bash_posix_path(project_root)
script_path = _bash_posix_path(script)
shim_dir = _bash_posix_path(_ensure_test_python_on_path(project_root))
command = (
f"export PATH={shlex_quote(shim_dir)}:\"$PATH\"; "
f"cd {shlex_quote(root)} && {shlex_quote(script_path)}"
)
return subprocess.run(
[BASH, "-lc", command],
env=env,
capture_output=True,
text=True,
timeout=30,
)
return subprocess.run(
[BASH, str(script)],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=30,
)
def shlex_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _run_powershell_agent_context_script(project_root: Path) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
env = _bundled_script_env(project_root)
return subprocess.run(
[
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=30,
)
def _run_powershell_agent_context_script_with_env(
project_root: Path,
*,
speckit_python: str,
) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
env = _bundled_script_env(project_root, speckit_python=speckit_python)
return subprocess.run(
[
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=30,
)
class TestContextMarkerResolution:
def test_defaults_when_ext_config_missing(self, tmp_path):
i = _CtxIntegration()
@@ -200,6 +398,142 @@ class TestUpsertWithCustomMarkers:
assert text.startswith("# header\n")
assert "footer" in text
def test_upsert_uses_configured_context_files(self, tmp_path):
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", "CLAUDE.md"],
)
i = _CtxIntegration()
result = i.upsert_context_section(
tmp_path, plan_path="specs/001-foo/plan.md"
)
assert result == tmp_path / "AGENTS.md"
for name in ("AGENTS.md", "CLAUDE.md"):
text = (tmp_path / name).read_text(encoding="utf-8")
assert IntegrationBase.CONTEXT_MARKER_START in text
assert "specs/001-foo/plan.md" in text
def test_context_files_deduplicate_with_platform_semantics(self, tmp_path):
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
)
files = _CtxIntegration()._resolve_context_files(tmp_path)
assert files == ["AGENTS.md", "CLAUDE.md"]
def test_empty_context_files_falls_back_to_config_context_file(self, tmp_path):
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=[],
)
files = _CtxIntegration()._resolve_context_files(tmp_path)
assert files == ["AGENTS.md"]
def test_config_context_file_takes_precedence_over_class_default(self, tmp_path):
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
)
i = _CtxIntegration()
result = i.upsert_context_section(
tmp_path, plan_path="specs/001-foo/plan.md"
)
assert result == tmp_path / "AGENTS.md"
assert (tmp_path / "AGENTS.md").exists()
assert not (tmp_path / "CLAUDE.md").exists()
def test_config_context_file_fallback_rejects_invalid_path(self, tmp_path):
_write_ext_config(
tmp_path,
context_file="../outside.md",
context_files=[],
)
with pytest.raises(ValueError, match="project-relative|must not contain"):
_CtxIntegration()._resolve_context_files(tmp_path)
def test_remove_uses_configured_context_files(self, tmp_path):
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", "CLAUDE.md"],
)
i = _CtxIntegration()
for name in ("AGENTS.md", "CLAUDE.md"):
(tmp_path / name).write_text(
f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n",
encoding="utf-8",
)
assert i.remove_context_section(tmp_path) is True
for name in ("AGENTS.md", "CLAUDE.md"):
text = (tmp_path / name).read_text(encoding="utf-8")
assert "body" not in text
assert "head" in text
assert "tail" in text
@pytest.mark.parametrize(
"bad_path",
[
"../outside.md",
"nested/../../outside.md",
"nested\\outside.md",
str(Path("/tmp/outside.md")),
"C:/tmp/outside.md",
"C:tmp/outside.md",
],
)
def test_upsert_rejects_context_files_outside_project(self, tmp_path, bad_path):
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", bad_path],
)
i = _CtxIntegration()
with pytest.raises(ValueError, match="project-relative|must not contain"):
i.upsert_context_section(tmp_path)
assert not (tmp_path / "AGENTS.md").exists()
assert not (tmp_path.parent / "outside.md").exists()
@pytest.mark.parametrize(
"bad_path",
[
"../outside.md",
"nested\\outside.md",
str(Path("/tmp/outside.md")),
"C:/tmp/outside.md",
"C:tmp/outside.md",
],
)
def test_remove_rejects_context_files_outside_project(self, tmp_path, bad_path):
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", bad_path],
)
outside = tmp_path.parent / "outside.md"
outside.write_text(
f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
f"{IntegrationBase.CONTEXT_MARKER_END}\n",
encoding="utf-8",
)
i = _CtxIntegration()
with pytest.raises(ValueError, match="project-relative|must not contain"):
i.remove_context_section(tmp_path)
assert "body" in outside.read_text(encoding="utf-8")
def test_remove_uses_custom_markers(self, tmp_path):
i = self._setup(
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
@@ -270,6 +604,17 @@ class TestExtensionEnabledGate:
assert result is None
assert not (tmp_path / "CLAUDE.md").exists()
def test_upsert_disabled_ignores_bad_context_files_config(self, tmp_path):
_write_registry(tmp_path, enabled=False)
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["../disabled-upsert-outside.md"],
)
i = _CtxIntegration()
assert i.upsert_context_section(tmp_path) is None
assert not (tmp_path.parent / "disabled-upsert-outside.md").exists()
def test_remove_skipped_when_disabled(self, tmp_path):
_write_registry(tmp_path, enabled=False)
i = _CtxIntegration()
@@ -283,6 +628,382 @@ class TestExtensionEnabledGate:
# File must be unchanged when extension is disabled
assert ctx.read_text(encoding="utf-8") == original
def test_remove_disabled_ignores_bad_context_files_config(self, tmp_path):
_write_registry(tmp_path, enabled=False)
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["../disabled-remove-outside.md"],
)
outside = tmp_path.parent / "disabled-remove-outside.md"
outside.write_text(
f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
f"{IntegrationBase.CONTEXT_MARKER_END}\n",
encoding="utf-8",
)
i = _CtxIntegration()
assert i.remove_context_section(tmp_path) is False
assert "body" in outside.read_text(encoding="utf-8")
def test_context_file_display_disabled_uses_config_context_file(
self, tmp_path
):
_write_registry(tmp_path, enabled=False)
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["../outside.md"],
)
i = _CtxIntegration()
assert i._context_file_display(tmp_path) == "AGENTS.md"
def test_context_file_display_disabled_without_context_file_returns_string(
self, tmp_path
):
_write_registry(tmp_path, enabled=False)
i = _NoContextIntegration()
assert i._context_file_display(tmp_path) == ""
class TestSkillPlaceholderContextValidation:
@pytest.mark.parametrize(
"bad_path",
[
"../outside.md",
"nested/../../outside.md",
"nested\\outside.md",
str(Path("/tmp/outside.md")),
"C:/tmp/outside.md",
"C:tmp/outside.md",
],
)
def test_context_files_reject_invalid_config_paths(self, tmp_path, bad_path):
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["AGENTS.md", bad_path],
)
with pytest.raises(ValueError, match="project-relative|must not contain"):
CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
@pytest.mark.parametrize(
"bad_path",
[
"../outside.md",
"C:tmp/outside.md",
],
)
def test_context_file_rejects_invalid_config_path(self, tmp_path, bad_path):
_write_ext_config(
tmp_path,
context_file=bad_path,
context_files=[],
)
with pytest.raises(ValueError, match="project-relative|must not contain"):
CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
def test_enabled_extension_rejects_invalid_legacy_init_options_path(
self, tmp_path
):
save_init_options(tmp_path, {"context_file": "../outside.md"})
with pytest.raises(ValueError, match="must not contain"):
CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
def test_disabled_extension_ignores_invalid_context_files(self, tmp_path):
_write_registry(tmp_path, enabled=False)
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["../outside.md"],
)
save_init_options(tmp_path, {"context_file": "AGENTS.md"})
content = CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
assert content == "Read AGENTS.md"
def test_disabled_extension_uses_extension_context_file_before_init_options(
self, tmp_path
):
_write_registry(tmp_path, enabled=False)
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["CLAUDE.md"],
)
save_init_options(tmp_path, {"context_file": "LEGACY.md"})
content = CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
assert content == "Read AGENTS.md"
def test_context_files_deduplicate_with_platform_semantics(self, tmp_path):
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
)
content = CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
assert content == "Read AGENTS.md, CLAUDE.md"
class TestBundledUpdaterPathValidation:
def test_bundled_script_env_makes_yaml_importable(self, tmp_path):
env = _bundled_script_env(tmp_path)
result = subprocess.run(
[env["SPECKIT_PYTHON"], "-c", "import yaml"],
env=env,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr + result.stdout
@requires_bash
def test_bash_script_trims_context_file_fallback(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file=" AGENTS.md ",
context_files=[],
)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
assert (project / "AGENTS.md").exists()
assert not (project / " AGENTS.md ").exists()
@requires_bash
def test_bash_script_rejects_symlink_escape(self, tmp_path):
project = tmp_path / "project"
outside = tmp_path / "outside"
project.mkdir()
outside.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["link/out.md"],
)
if os.name == "nt":
root = _bash_posix_path(tmp_path)
create_link = subprocess.run(
[
BASH,
"-lc",
f"ln -s {shlex_quote(root + '/outside')} "
f"{shlex_quote(root + '/project/link')}",
],
capture_output=True,
text=True,
timeout=30,
)
if create_link.returncode != 0:
pytest.skip(f"symlink unavailable: {create_link.stderr}")
else:
try:
(project / "link").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlink unavailable: {exc}")
result = _run_bash_agent_context_script(project)
assert result.returncode == 1
assert "resolves outside the project root" in result.stderr
assert not (outside / "out.md").exists()
@requires_bash
def test_bash_script_deduplicates_context_files_in_order(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
output = result.stderr + result.stdout
assert output.count("agent-context: updated AGENTS.md") == 1
assert output.count("agent-context: updated CLAUDE.md") == 1
assert "agent-context: updated agents.md" not in output
@requires_bash
def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
result = _run_bash_agent_context_script(
project,
speckit_python="/definitely/missing/python",
)
assert result.returncode == 0, result.stderr + result.stdout
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
assert (project / "AGENTS.md").exists()
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_rejects_backslash_context_files(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["nested\\AGENTS.md"],
)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 1
assert "must not contain backslash separators" in (
result.stderr + result.stdout
)
assert not (project / "nested" / "AGENTS.md").exists()
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_rejects_drive_qualified_context_files(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["C:tmp/outside.md"],
)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 1
assert "must be project-relative paths" in (result.stderr + result.stdout)
assert not (project / "tmp" / "outside.md").exists()
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_deduplicates_context_files_in_order(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
output = result.stderr + result.stdout
assert output.count("agent-context: updated AGENTS.md") == 1
assert output.count("agent-context: updated CLAUDE.md") == 1
assert "agent-context: updated agents.md" not in output
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
result = _run_powershell_agent_context_script_with_env(
project,
speckit_python=str(project / "missing-python"),
)
assert result.returncode == 0, result.stderr + result.stdout
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
assert (project / "AGENTS.md").exists()
@pytest.mark.skipif(
POWERSHELL is None or os.name != "nt",
reason="Windows PowerShell junction test requires Windows",
)
def test_powershell_script_rejects_junction_escape(self, tmp_path):
project = tmp_path / "project"
outside = tmp_path / "outside"
project.mkdir()
outside.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["link/out.md"],
)
create_link = subprocess.run(
[
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
(
"New-Item -ItemType Junction "
f"-Path {str(project / 'link')!r} "
f"-Target {str(outside)!r} | Out-Null"
),
],
capture_output=True,
text=True,
timeout=30,
)
if create_link.returncode != 0:
pytest.skip(f"junction unavailable: {create_link.stderr}")
result = _run_powershell_agent_context_script(project)
assert result.returncode == 1
assert "resolves outside the project root" in (result.stderr + result.stdout)
assert not (outside / "out.md").exists()
# ── Extension config writers ─────────────────────────────────────────────────
@@ -349,6 +1070,65 @@ class TestExtensionConfigWriters:
assert cfg["context_file"] == i.context_file
assert "context_markers" in cfg
def test_update_init_options_preserves_context_files(self, tmp_path):
from specify_cli import _update_init_options_for_integration
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["AGENTS.md", "CLAUDE.md"],
)
i = _CtxIntegration()
_update_init_options_for_integration(tmp_path, i, script_type="sh")
cfg = _load_agent_context_config(tmp_path)
assert cfg["context_file"] == i.context_file
assert cfg["context_files"] == ["AGENTS.md", "CLAUDE.md"]
def test_update_init_options_preserves_empty_context_files(self, tmp_path):
from specify_cli import _update_init_options_for_integration
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=[],
)
i = _CtxIntegration()
_update_init_options_for_integration(tmp_path, i, script_type="sh")
cfg = _load_agent_context_config(tmp_path)
assert cfg["context_file"] == i.context_file
assert cfg["context_files"] == []
def test_update_init_options_normalizes_invalid_context_files(self, tmp_path):
from specify_cli import _update_init_options_for_integration
_write_ext_config(tmp_path, context_file="AGENTS.md")
cfg = _load_agent_context_config(tmp_path)
cfg["context_files"] = "AGENTS.md"
_save_agent_context_config(tmp_path, cfg)
i = _CtxIntegration()
_update_init_options_for_integration(tmp_path, i, script_type="sh")
cfg = _load_agent_context_config(tmp_path)
assert cfg["context_file"] == i.context_file
assert cfg["context_files"] == []
def test_clear_init_options_clears_context_files(self, tmp_path):
from specify_cli import _clear_init_options_for_integration
save_init_options(
tmp_path,
{"integration": "claude", "ai": "claude"},
)
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", "CLAUDE.md"],
)
_clear_init_options_for_integration(tmp_path, "claude")
cfg = _load_agent_context_config(tmp_path)
assert cfg.get("context_file") == ""
assert "context_files" not in cfg
def test_update_init_options_preserves_custom_markers(self, tmp_path):
from specify_cli import _update_init_options_for_integration

View File

@@ -0,0 +1,211 @@
"""Tests that update-agent-context.sh/.ps1 prefer feature.json over mtime."""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
import pytest
from tests.conftest import requires_bash
from tests.extensions.test_extension_agent_context import (
BASH,
POWERSHELL,
_bash_posix_path,
_run_bash_agent_context_script,
_run_powershell_agent_context_script,
)
def _setup_project(root: Path, context_file: str = "CLAUDE.md") -> None:
"""Write agent-context extension config as JSON.
JSON is valid YAML so bash+PyYAML can parse it, and PowerShell's built-in
ConvertFrom-Json can parse it without needing powershell-yaml or Python.
Written directly as JSON (not via yaml.safe_dump) so the PS ConvertFrom-Json
fallback actually works on Windows CI.
"""
cfg_dir = root / ".specify" / "extensions" / "agent-context"
cfg_dir.mkdir(parents=True, exist_ok=True)
(cfg_dir / "agent-context-config.yml").write_text(
json.dumps({
"context_file": context_file,
"context_markers": {
"start": "<!-- SPECKIT START -->",
"end": "<!-- SPECKIT END -->",
},
}),
encoding="utf-8",
)
def _write_feature_json(root: Path, feature_directory: str) -> None:
specify_dir = root / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
(specify_dir / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _make_plan(root: Path, feature_dir: str, content: str = "# plan\n") -> Path:
p = root / feature_dir / "plan.md"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return p
@requires_bash
def test_bash_uses_feature_json_when_plan_exists(tmp_path: Path) -> None:
"""feature.json points to the active feature; that plan.md is injected."""
_setup_project(tmp_path)
_make_plan(tmp_path, "specs/001-active")
_write_feature_json(tmp_path, "specs/001-active")
result = _run_bash_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/001-active/plan.md" in ctx
@requires_bash
def test_bash_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None:
"""An older spec's plan.md modified more recently must NOT win over feature.json."""
_setup_project(tmp_path)
active = _make_plan(tmp_path, "specs/001-active")
stale = _make_plan(tmp_path, "specs/000-stale")
now = time.time()
os.utime(active, (now - 10, now - 10))
os.utime(stale, (now, now))
_write_feature_json(tmp_path, "specs/001-active")
result = _run_bash_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/001-active/plan.md" in ctx
assert "specs/000-stale/plan.md" not in ctx
@requires_bash
def test_bash_falls_back_to_mtime_when_feature_json_absent(tmp_path: Path) -> None:
"""No feature.json → mtime fallback selects the most recently modified plan."""
_setup_project(tmp_path)
old = _make_plan(tmp_path, "specs/000-old")
newer = _make_plan(tmp_path, "specs/001-newer")
now = time.time()
os.utime(old, (now - 10, now - 10))
os.utime(newer, (now, now))
result = _run_bash_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/001-newer/plan.md" in ctx
@requires_bash
def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> None:
"""feature.json exists but plan.md not yet written → fall back to mtime."""
_setup_project(tmp_path)
_make_plan(tmp_path, "specs/000-old")
_write_feature_json(tmp_path, "specs/001-new")
result = _run_bash_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/000-old/plan.md" in ctx
@requires_bash
def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None:
"""Absolute feature_directory under PROJECT_ROOT → project-relative path in context."""
_setup_project(tmp_path)
active = _make_plan(tmp_path, "specs/001-active")
stale = _make_plan(tmp_path, "specs/000-stale")
now = time.time()
os.utime(active, (now - 10, now - 10))
os.utime(stale, (now, now))
# Write POSIX absolute path — mtime would pick 000-stale without feature.json
_write_feature_json(tmp_path, _bash_posix_path(tmp_path / "specs" / "001-active"))
result = _run_bash_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/001-active/plan.md" in ctx
assert "specs/000-stale/plan.md" not in ctx
assert _bash_posix_path(tmp_path) not in ctx
@requires_bash
def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None:
"""Absolute feature_directory outside PROJECT_ROOT → absolute path preserved in context."""
project = tmp_path / "project"
external = tmp_path / "external" / "001-feature"
project.mkdir()
external.mkdir(parents=True)
(external / "plan.md").write_text("# plan\n", encoding="utf-8")
_setup_project(project)
_write_feature_json(project, _bash_posix_path(external))
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (project / "CLAUDE.md").read_text(encoding="utf-8")
assert _bash_posix_path(external) + "/plan.md" in ctx
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None:
"""PowerShell: absolute feature_directory under project root is normalized to relative path."""
_setup_project(tmp_path)
active = _make_plan(tmp_path, "specs/001-active")
stale = _make_plan(tmp_path, "specs/000-stale")
now = time.time()
os.utime(active, (now - 10, now - 10))
os.utime(stale, (now, now))
# Native str() — PowerShell expects Windows-native paths, not MSYS2 /c/... form
_write_feature_json(tmp_path, str(tmp_path / "specs" / "001-active"))
result = _run_powershell_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "at specs/001-active/plan.md" in ctx
assert "specs/000-stale/plan.md" not in ctx
assert tmp_path.resolve().as_posix() not in ctx
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_ps_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None:
"""PowerShell: stale plan touched more recently must not win over feature.json."""
_setup_project(tmp_path)
active = _make_plan(tmp_path, "specs/001-active")
stale = _make_plan(tmp_path, "specs/000-stale")
now = time.time()
os.utime(active, (now - 10, now - 10))
os.utime(stale, (now, now))
_write_feature_json(tmp_path, "specs/001-active")
result = _run_powershell_agent_context_script(tmp_path)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/001-active/plan.md" in ctx
assert "specs/000-stale/plan.md" not in ctx
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
def test_ps_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None:
"""PowerShell: absolute feature_directory outside project root → absolute path preserved."""
project = tmp_path / "project"
external = tmp_path / "external" / "001-feature"
project.mkdir()
external.mkdir(parents=True)
(external / "plan.md").write_text("# plan\n", encoding="utf-8")
_setup_project(project)
_write_feature_json(project, str(external))
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
ctx = (project / "CLAUDE.md").read_text(encoding="utf-8")
assert external.resolve().as_posix() + "/plan.md" in ctx

View File

@@ -263,6 +263,206 @@ class TestInitIntegrationFlag:
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_removes_stale_managed_script(self, tmp_path):
"""A managed script the core no longer ships (e.g. the legacy
update-agent-context.sh, superseded by the agent-context extension) is
removed, and the manifest stops tracking it (#3076)."""
from specify_cli import _install_shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "stale-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
# Legacy orphan the current bundle no longer ships, recorded in the
# manifest as a managed file (hash matches on disk) — a pre-refactor install.
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
(scripts_dir / "update-agent-context.sh").write_text("# legacy orphan\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(stale_rel)
manifest.save()
_install_shared_infra(project, "sh", force=False)
# The orphan is gone and the manifest no longer tracks it.
assert not (scripts_dir / "update-agent-context.sh").exists()
refreshed = IntegrationManifest.load("speckit", project)
assert stale_rel not in refreshed.files
# Scripts the core DOES ship are installed and tracked.
assert (scripts_dir / "common.sh").exists()
assert ".specify/scripts/bash/common.sh" in refreshed.files
def test_shared_infra_preserves_modified_stale_script(self, tmp_path):
"""A user-modified stale script is preserved (hash diverges from the
managed baseline), never silently deleted (#3076)."""
from specify_cli import _install_shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "stale-modified"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
stale = scripts_dir / "update-agent-context.sh"
stale.write_text("# original managed\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(".specify/scripts/bash/update-agent-context.sh")
manifest.save()
# User customizes it after install → on-disk hash now diverges.
stale.write_text("# user customization\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=False)
# Preserved: it is no longer a managed (hash-matching) copy.
assert stale.exists()
assert stale.read_text(encoding="utf-8") == "# user customization\n"
def test_shared_infra_prunes_orphan_manifest_entry_when_file_absent(self, tmp_path):
"""A stale manifest entry whose file is already gone from disk is pruned
so the manifest stays consistent, not left tracked forever (#3076 review)."""
from specify_cli import _install_shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "orphan-entry"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
stale = scripts_dir / "update-agent-context.sh"
stale.write_text("# legacy orphan\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(stale_rel)
manifest.save()
# File removed out of band, but the manifest still tracks it.
stale.unlink()
_install_shared_infra(project, "sh", force=False)
refreshed = IntegrationManifest.load("speckit", project)
assert stale_rel not in refreshed.files
def test_shared_infra_empty_script_source_keeps_tracked_scripts(self, tmp_path, monkeypatch):
"""If the bundle's script source dir exists but is empty, stale-cleanup
must NOT run (no source files seen → can't tell what's obsolete): a
previously-tracked script is preserved, never mass-deleted (#3076 review)."""
from specify_cli import _install_shared_infra, shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
# Point the script source at an empty ``bash/`` directory.
empty_src = tmp_path / "empty-bundle" / "scripts"
(empty_src / "bash").mkdir(parents=True)
monkeypatch.setattr(shared_infra, "shared_scripts_source", lambda **kw: empty_src)
project = tmp_path / "empty-source"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
tracked_rel = ".specify/scripts/bash/common.sh"
(scripts_dir / "common.sh").write_text("# tracked\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(tracked_rel)
manifest.save()
_install_shared_infra(project, "sh", force=False)
# Empty source → scripts_scanned stays False → nothing deleted.
assert (scripts_dir / "common.sh").exists()
refreshed = IntegrationManifest.load("speckit", project)
assert tracked_rel in refreshed.files
def test_shared_infra_stale_cleanup_ignores_unsafe_manifest_keys(self, tmp_path):
"""A corrupted/hand-edited manifest key with a ``..`` segment is skipped
before any filesystem access — its traversal target is never deleted
(#3076 review, containment guard)."""
import hashlib
import json
from specify_cli import _install_shared_infra
project = tmp_path / "unsafe-key"
project.mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
manifest_dir = project / ".specify" / "integrations"
manifest_dir.mkdir(parents=True)
# A file the traversal key would resolve to (outside scripts/bash/).
victim = project / ".specify" / "scripts" / "keep-me.sh"
victim_bytes = b"# do not touch\n"
victim.write_bytes(victim_bytes)
# Hand-crafted manifest: a key under the script prefix but with a ``..``
# segment, with the *matching* hash so that — absent the containment guard
# — stale-cleanup would consider it managed and unlink the target.
traversal_key = ".specify/scripts/bash/../keep-me.sh"
(manifest_dir / "speckit.manifest.json").write_text(
json.dumps({
"integration": "speckit",
"version": "test",
"files": {traversal_key: hashlib.sha256(victim_bytes).hexdigest()},
}),
encoding="utf-8",
)
_install_shared_infra(project, "sh", force=False)
# The unsafe key was skipped; its target file is untouched.
assert victim.exists()
assert victim.read_bytes() == victim_bytes
def test_shared_infra_stale_cleanup_skips_escaping_key_without_failing(
self, tmp_path, monkeypatch
):
"""A key that passes the lexical guard but escapes containment — e.g. a
Windows drive-relative ``C:tmp`` that is not ``is_absolute()`` yet discards
the project root when joined — is skipped via ``_validate_rel_path``, never
unlinked, and never turned into an install-time hard failure (#3076 review
round 4). Simulated portably by forcing ``_validate_rel_path`` to reject the
managed key, since real drive-relative paths only escape on Windows."""
from specify_cli import _install_shared_infra
from specify_cli.integrations import manifest as manifest_mod
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "escaping-key"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
# A managed stale orphan that would normally be removed.
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
stale = scripts_dir / "update-agent-context.sh"
stale.write_text("# legacy orphan\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(stale_rel)
manifest.save()
# Force the containment check to reject this key, as it would for a
# drive-relative escape on Windows. The cleanup must skip it gracefully.
real_validate = manifest_mod._validate_rel_path
def fake_validate(rel, root):
if str(rel).endswith("update-agent-context.sh"):
raise ValueError("simulated drive-relative escape")
return real_validate(rel, root)
monkeypatch.setattr(manifest_mod, "_validate_rel_path", fake_validate)
# Must not raise (no install-time hard failure from a corrupted key).
_install_shared_infra(project, "sh", force=False)
# The escaping key was skipped, so its file is left untouched...
assert stale.exists()
assert stale.read_text(encoding="utf-8") == "# legacy orphan\n"
# ...yet the install otherwise completed: real scripts are installed.
assert (scripts_dir / "common.sh").exists()
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
"""Console warning is displayed when files are skipped."""
from specify_cli import _install_shared_infra
@@ -1315,6 +1515,78 @@ class TestIntegrationCatalogDiscoveryCLI:
assert extension_list.exit_code == 0, extension_list.output
assert "Config: .specify/extension-catalogs.yml" in extension_list.output
def test_extension_catalog_add_rejects_non_mapping_config_root(self, tmp_path):
project = self._make_project(tmp_path)
cfg_path = project / ".specify" / "extension-catalogs.yml"
cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8")
result = self._invoke([
"extension", "catalog", "add",
"https://example.com/extension-catalog.yml",
"--name", "demo-extensions",
], project)
assert result.exit_code == 1, result.output
output = _normalize_cli_output(result.output)
assert "Invalid catalog config .specify/extension-catalogs.yml" in output
assert "expected a YAML mapping at the root" in output
assert "AttributeError" not in output
def test_extension_catalog_remove_rejects_non_mapping_config_root(self, tmp_path):
project = self._make_project(tmp_path)
cfg_path = project / ".specify" / "extension-catalogs.yml"
cfg_path.write_text("- not\n- a\n- mapping\n", encoding="utf-8")
result = self._invoke(["extension", "catalog", "remove", "demo"], project)
assert result.exit_code == 1, result.output
output = _normalize_cli_output(result.output)
assert "Invalid catalog config .specify/extension-catalogs.yml" in output
assert "expected a YAML mapping at the root" in output
assert "AttributeError" not in output
def test_extension_catalog_add_escapes_catalog_name_markup(self, tmp_path):
project = self._make_project(tmp_path)
catalog_name = "[red]demo[/red]"
result = self._invoke([
"extension", "catalog", "add",
"https://example.com/extension-catalog.yml",
"--name", catalog_name,
], project)
assert result.exit_code == 0, result.output
output = _normalize_cli_output(result.output)
assert f"Added catalog '{catalog_name}'" in output
def test_extension_catalog_remove_escapes_catalog_name_markup(self, tmp_path):
project = self._make_project(tmp_path)
catalog_name = "[red]demo[/red]"
cfg_path = project / ".specify" / "extension-catalogs.yml"
cfg_path.write_text(
yaml.safe_dump(
{
"catalogs": [
{
"name": catalog_name,
"url": "https://example.com/extension-catalog.yml",
"priority": 10,
"install_allowed": False,
"description": "",
}
]
},
sort_keys=False,
),
encoding="utf-8",
)
result = self._invoke(["extension", "catalog", "remove", catalog_name], project)
assert result.exit_code == 0, result.output
output = _normalize_cli_output(result.output)
assert f"Removed catalog '{catalog_name}'" in output
# -- search ------------------------------------------------------------
def test_search_lists_all(self, tmp_path, monkeypatch):

View File

@@ -539,8 +539,16 @@ class TestClaudeDisableModelInvocation:
class TestClaudeForkContext:
"""Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS."""
def test_analyze_skill_runs_in_forked_subagent(self, tmp_path):
"""speckit-analyze must opt into context: fork + agent."""
def test_no_commands_fork_by_default(self):
"""FORK_CONTEXT_COMMANDS is empty: no command opts into context: fork.
``analyze`` was removed (#3185) because its verbose report defeated the
purpose of forking and compounded context overhead across repeated runs.
"""
assert FORK_CONTEXT_COMMANDS == {}
def test_analyze_skill_does_not_fork(self, tmp_path):
"""speckit-analyze must run in the main session, not a forked subagent (#3185)."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")
@@ -549,10 +557,10 @@ class TestClaudeForkContext:
content = analyze_skill.read_text(encoding="utf-8")
parts = content.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert parsed.get("context") == "fork"
assert parsed.get("agent") == "general-purpose"
assert "context" not in parsed
assert "agent" not in parsed
def test_other_skills_do_not_fork(self, tmp_path):
def test_no_skills_fork(self, tmp_path):
"""Skills not in FORK_CONTEXT_COMMANDS must not get context: fork."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
@@ -574,60 +582,39 @@ class TestClaudeForkContext:
f"{f.parent.name}: must not have agent frontmatter"
)
def test_fork_flags_inside_frontmatter(self, tmp_path):
"""context/agent must appear in the frontmatter, not in the body."""
def test_post_process_no_fork_for_skills(self):
"""With FORK_CONTEXT_COMMANDS empty, post_process must not add context/agent."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
content = analyze_skill.read_text(encoding="utf-8")
parts = content.split("---", 2)
assert len(parts) >= 3
frontmatter = parts[1]
body = parts[2]
assert "context: fork" in frontmatter
assert "agent: general-purpose" in frontmatter
assert "context: fork" not in body
assert "agent: general-purpose" not in body
for name in ("speckit-analyze", "speckit-plan"):
content = f'---\nname: "{name}"\ndescription: "x"\n---\n\nBody\n'
result = i.post_process_skill_content(content)
parsed = yaml.safe_load(result.split("---", 2)[1])
assert "context" not in parsed
assert "agent" not in parsed
def test_fork_injection_idempotent(self, tmp_path):
"""Re-running setup must not duplicate the fork frontmatter keys."""
i = get_integration("claude")
m = IntegrationManifest("claude", tmp_path)
i.setup(tmp_path, m, script_type="sh")
i.setup(tmp_path, m, script_type="sh")
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
content = analyze_skill.read_text(encoding="utf-8")
assert content.count("context: fork") == 1
assert content.count("agent: general-purpose") == 1
def test_fork_mechanism_injects_when_configured(self, monkeypatch):
"""The injection mechanism still works for any command added to
FORK_CONTEXT_COMMANDS, even though none ships enabled by default."""
import specify_cli.integrations.claude as claude_mod
def test_fork_context_injected_via_post_process(self):
"""Preset/extension generators call post_process_skill_content directly,
bypassing setup(); fork context must be injected there too."""
monkeypatch.setitem(
claude_mod.FORK_CONTEXT_COMMANDS,
"analyze",
{"context": "fork", "agent": "general-purpose"},
)
i = get_integration("claude")
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
result = i.post_process_skill_content(content)
parsed = yaml.safe_load(result.split("---", 2)[1])
parts = result.split("---", 2)
parsed = yaml.safe_load(parts[1])
assert parsed.get("context") == "fork"
assert parsed.get("agent") == "general-purpose"
assert parsed.get("argument-hint") == ARGUMENT_HINTS["analyze"]
def test_post_process_no_fork_for_other_skills(self):
"""Skills not in FORK_CONTEXT_COMMANDS must not gain context/agent."""
i = get_integration("claude")
content = '---\nname: "speckit-plan"\ndescription: "x"\n---\n\nBody\n'
result = i.post_process_skill_content(content)
parsed = yaml.safe_load(result.split("---", 2)[1])
assert "context" not in parsed
assert "agent" not in parsed
def test_post_process_fork_idempotent(self):
"""Re-running post_process must not duplicate fork frontmatter keys."""
i = get_integration("claude")
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
once = i.post_process_skill_content(content)
twice = i.post_process_skill_content(once)
assert once == twice
# Flags must land in the frontmatter, not the body.
assert "context: fork" in parts[1]
assert "context: fork" not in parts[2]
# Re-running must not duplicate the injected keys.
twice = i.post_process_skill_content(result)
assert result == twice
assert twice.count("context: fork") == 1
assert twice.count("agent: general-purpose") == 1

View File

@@ -29,6 +29,80 @@ class TestCodexInitFlow:
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_plan_skill_references_configured_context_files(self, tmp_path):
"""Plan skill should render all configured agent context files."""
from specify_cli import _save_agent_context_config
target = tmp_path / "test-proj"
target.mkdir()
_save_agent_context_config(
target,
{
"context_file": "AGENTS.md",
"context_files": ["AGENTS.md", "CLAUDE.md"],
"context_markers": {
"start": "<!-- SPECKIT START -->",
"end": "<!-- SPECKIT END -->",
},
},
)
integration = get_integration("codex")
manifest = IntegrationManifest("codex", target)
integration.setup(target, manifest, script_type="sh")
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
content = plan_skill.read_text(encoding="utf-8")
assert "AGENTS.md, CLAUDE.md" in content
assert "__CONTEXT_FILE__" not in content
def test_plan_skill_ignores_context_files_when_agent_context_disabled(
self, tmp_path
):
"""Disabled agent-context must not leak stale context_files into commands."""
from specify_cli import _save_agent_context_config
target = tmp_path / "test-proj"
target.mkdir()
registry = target / ".specify" / "extensions" / ".registry"
registry.parent.mkdir(parents=True, exist_ok=True)
registry.write_text(
"""
{
"schema_version": "1.0",
"extensions": {
"agent-context": {
"version": "1.0.0",
"enabled": false
}
}
}
""".strip(),
encoding="utf-8",
)
_save_agent_context_config(
target,
{
"context_file": "AGENTS.md",
"context_files": ["../outside.md", "CLAUDE.md"],
"context_markers": {
"start": "<!-- SPECKIT START -->",
"end": "<!-- SPECKIT END -->",
},
},
)
integration = get_integration("codex")
manifest = IntegrationManifest("codex", target)
integration.setup(target, manifest, script_type="sh")
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
content = plan_skill.read_text(encoding="utf-8")
assert "AGENTS.md, CLAUDE.md" not in content
assert "../outside.md" not in content
assert "AGENTS.md" in content
assert "__CONTEXT_FILE__" not in content
class TestCodexHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected in hook sections.

View File

@@ -0,0 +1,45 @@
"""Tests for FirebenderIntegration."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestFirebenderIntegration(MarkdownIntegrationTests):
KEY = "firebender"
FOLDER = ".firebender/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".firebender/commands"
CONTEXT_FILE = ".firebender/rules/specify-rules.mdc"
# Firebender reads custom slash commands from ``.firebender/commands/*.mdc``,
# so this integration uses the ``.mdc`` extension instead of the ``.md``
# default the base mixin assumes. Override the two extension-specific tests.
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "markdown"
assert i.registrar_config["args"] == "$ARGUMENTS"
assert i.registrar_config["extension"] == ".mdc"
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(".mdc")
def _expected_files(self, script_variant: str) -> list[str]:
# Firebender emits ``.mdc`` command files, so remap the base mixin's
# ``.md`` expectations for files under this integration's command dir.
cmd_dir = get_integration(self.KEY).registrar_config["dir"]
prefix = cmd_dir + "/"
return sorted(
f[:-3] + ".mdc" if f.startswith(prefix) and f.endswith(".md") else f
for f in super()._expected_files(script_variant)
)

View File

@@ -1,18 +1,42 @@
"""Tests for KimiIntegration — skills integration with legacy migration."""
from pathlib import Path
import pytest
from specify_cli.integrations import get_integration
from specify_cli.integrations.kimi import _migrate_legacy_kimi_dotted_skills
from specify_cli.integrations.kimi import (
_migrate_legacy_kimi_context_file,
_migrate_legacy_kimi_dotted_skills,
_migrate_legacy_kimi_skills_dir,
)
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_skills import SkillsIntegrationTests
def _symlink_or_skip(
link: Path, target: Path, *, target_is_directory: bool = False
) -> None:
"""Create *link* pointing at *target*, skipping the test if unsupported.
Symlink creation fails on Windows without the create-symlink privilege and
in some restricted CI sandboxes. The symlink-safety tests below assert
behavior that only matters when symlinks exist, so skip (rather than error)
when the platform cannot create them.
"""
try:
link.symlink_to(target, target_is_directory=target_is_directory)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"symlinks unavailable: {exc}")
class TestKimiIntegration(SkillsIntegrationTests):
KEY = "kimi"
FOLDER = ".kimi/"
FOLDER = ".kimi-code/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".kimi/skills"
CONTEXT_FILE = "KIMI.md"
REGISTRAR_DIR = ".kimi-code/skills"
CONTEXT_FILE = "AGENTS.md"
class TestKimiOptions:
@@ -103,12 +127,13 @@ class TestKimiLegacyMigration:
assert migrated == 0
assert removed == 0
def test_setup_with_migrate_legacy_option(self, tmp_path):
"""KimiIntegration.setup() with --migrate-legacy migrates dotted dirs."""
def test_setup_migrate_legacy_moves_old_skills_dir(self, tmp_path):
"""--migrate-legacy moves hyphenated skills from .kimi/skills to .kimi-code/skills."""
i = get_integration("kimi")
skills_dir = tmp_path / ".kimi" / "skills"
legacy = skills_dir / "speckit.oldcmd"
old_skills_dir = tmp_path / ".kimi" / "skills"
new_skills_dir = tmp_path / ".kimi-code" / "skills"
legacy = old_skills_dir / "speckit-oldcmd"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Legacy\n")
@@ -116,9 +141,428 @@ class TestKimiLegacyMigration:
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
assert not legacy.exists()
assert (skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
assert not old_skills_dir.exists()
assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
# New skills from templates should also exist
assert (skills_dir / "speckit-specify" / "SKILL.md").exists()
assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists()
def test_setup_with_migrate_legacy_option(self, tmp_path):
"""KimiIntegration.setup() with --migrate-legacy migrates dotted dirs."""
i = get_integration("kimi")
old_skills_dir = tmp_path / ".kimi" / "skills"
new_skills_dir = tmp_path / ".kimi-code" / "skills"
legacy = old_skills_dir / "speckit.oldcmd"
legacy.mkdir(parents=True)
(legacy / "SKILL.md").write_text("# Legacy\n")
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
assert not legacy.exists()
assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
# New skills from templates should also exist
assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists()
class TestKimiContextFileMigration:
"""KIMI.md → AGENTS.md migration under --migrate-legacy."""
def test_setup_migrate_legacy_moves_kimi_md_user_content(self, tmp_path):
i = get_integration("kimi")
kimi_md = tmp_path / "KIMI.md"
kimi_md.write_text(
"# Project context\n\n"
"<!-- SPECKIT START -->\n"
"old managed section\n"
"<!-- SPECKIT END -->\n\n"
"Keep this user note.\n"
)
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
agents_md = tmp_path / "AGENTS.md"
assert agents_md.exists()
content = agents_md.read_text(encoding="utf-8")
assert "Keep this user note." in content
assert "old managed section" not in content
assert "<!-- SPECKIT START -->" in content
assert not kimi_md.exists()
def test_setup_migrate_legacy_removes_empty_kimi_md(self, tmp_path):
i = get_integration("kimi")
kimi_md = tmp_path / "KIMI.md"
kimi_md.write_text(
"<!-- SPECKIT START -->\n"
"only managed section\n"
"<!-- SPECKIT END -->\n"
)
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
assert (tmp_path / "AGENTS.md").exists()
assert not kimi_md.exists()
def test_setup_migrate_legacy_appends_to_existing_agents_md(self, tmp_path):
i = get_integration("kimi")
agents_md = tmp_path / "AGENTS.md"
agents_md.write_text("# Existing AGENTS.md\n\nExisting note.\n")
kimi_md = tmp_path / "KIMI.md"
kimi_md.write_text("# Kimi context\n\nKimi-specific note.\n")
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
content = agents_md.read_text(encoding="utf-8")
assert "Existing note." in content
assert "Kimi-specific note." in content
assert "<!-- SPECKIT START -->" in content
assert not kimi_md.exists()
def test_setup_migrate_legacy_uses_custom_context_markers(self, tmp_path):
"""Migration respects context_markers from agent-context extension config."""
i = get_integration("kimi")
config_dir = tmp_path / ".specify" / "extensions" / "agent-context"
config_dir.mkdir(parents=True)
(config_dir / "agent-context-config.yml").write_text(
"context_file: AGENTS.md\n"
"context_markers:\n"
" start: '<!-- CUSTOM START -->'\n"
" end: '<!-- CUSTOM END -->'\n"
)
kimi_md = tmp_path / "KIMI.md"
kimi_md.write_text(
"# Project context\n\n"
"<!-- CUSTOM START -->\n"
"old managed section\n"
"<!-- CUSTOM END -->\n\n"
"Keep this user note.\n"
)
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
agents_md = tmp_path / "AGENTS.md"
assert agents_md.exists()
content = agents_md.read_text(encoding="utf-8")
assert "Keep this user note." in content
assert "old managed section" not in content
assert "<!-- CUSTOM START -->" in content
assert "<!-- CUSTOM END -->" in content
assert "<!-- SPECKIT START -->" not in content
assert not kimi_md.exists()
def test_setup_migrate_legacy_skipped_when_agent_context_disabled(
self, tmp_path
):
"""A disabled agent-context extension opts out of KIMI.md migration."""
i = get_integration("kimi")
registry = tmp_path / ".specify" / "extensions" / ".registry"
registry.parent.mkdir(parents=True)
registry.write_text('{"extensions": {"agent-context": {"enabled": false}}}')
kimi_md = tmp_path / "KIMI.md"
kimi_md.write_text("# Kimi context\n\nKeep this user note.\n")
m = IntegrationManifest("kimi", tmp_path)
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
# Opted-out project: KIMI.md is left untouched and AGENTS.md is not
# created/modified by the migration.
assert kimi_md.is_file()
assert kimi_md.read_text() == "# Kimi context\n\nKeep this user note.\n"
assert not (tmp_path / "AGENTS.md").exists()
def test_context_migration_skips_corrupted_single_marker(self, tmp_path):
"""A KIMI.md with only a start marker is left untouched (no leak)."""
project = tmp_path
kimi_md = project / "KIMI.md"
kimi_md.write_text(
"# Notes\n\n"
"<!-- SPECKIT START -->\n"
"dangling managed content\n"
)
result = _migrate_legacy_kimi_context_file(project)
assert result is False
# KIMI.md untouched; managed block never copied into AGENTS.md.
assert kimi_md.is_file()
assert "dangling managed content" in kimi_md.read_text()
assert not (project / "AGENTS.md").exists()
def test_context_migration_skips_unreadable_kimi_md(self, tmp_path):
"""Non-UTF-8 KIMI.md is skipped instead of raising during setup."""
project = tmp_path
kimi_md = project / "KIMI.md"
kimi_md.write_bytes(b"\xff\xfe invalid utf-8 \xa6\n")
result = _migrate_legacy_kimi_context_file(project)
assert result is False
assert kimi_md.is_file()
assert not (project / "AGENTS.md").exists()
def test_context_migration_skips_when_agents_md_is_directory(self, tmp_path):
"""An AGENTS.md that exists as a directory is skipped, not written to."""
project = tmp_path
(project / "AGENTS.md").mkdir()
kimi_md = project / "KIMI.md"
kimi_md.write_text("# Notes\n\nKeep this.\n")
result = _migrate_legacy_kimi_context_file(project)
assert result is False
# KIMI.md is preserved and the directory is untouched.
assert kimi_md.is_file()
assert (project / "AGENTS.md").is_dir()
class TestKimiTeardownLegacyCleanup:
"""teardown() removes leftover legacy .kimi/skills/ directories."""
def test_teardown_removes_legacy_speckit_skills(self, tmp_path):
i = get_integration("kimi")
legacy_skill = tmp_path / ".kimi" / "skills" / "speckit-plan" / "SKILL.md"
legacy_skill.parent.mkdir(parents=True)
legacy_skill.write_text(
"---\n"
"name: \"speckit-plan\"\n"
"description: \"Plan workflow\"\n"
"metadata:\n"
" author: \"github-spec-kit\"\n"
" source: \"templates/commands/plan.md\"\n"
"---\n"
)
m = IntegrationManifest("kimi", tmp_path)
i.teardown(tmp_path, m)
assert not legacy_skill.exists()
assert not (tmp_path / ".kimi" / "skills").exists()
def test_teardown_preserves_user_skills_in_legacy_dir(self, tmp_path):
i = get_integration("kimi")
user_skill = tmp_path / ".kimi" / "skills" / "my-custom" / "SKILL.md"
user_skill.parent.mkdir(parents=True)
user_skill.write_text("# My custom skill\n")
m = IntegrationManifest("kimi", tmp_path)
i.teardown(tmp_path, m)
assert user_skill.exists()
class TestKimiCommandInvocation:
"""Kimi dispatch must use the native ``/skill:`` slash command."""
def test_build_command_invocation_uses_skill_prefix(self):
i = get_integration("kimi")
assert i.build_command_invocation("specify") == "/skill:speckit-specify"
assert i.build_command_invocation("speckit.plan") == "/skill:speckit-plan"
def test_build_command_invocation_dotted_extension(self):
i = get_integration("kimi")
assert (
i.build_command_invocation("speckit.git.commit")
== "/skill:speckit-git-commit"
)
def test_build_command_invocation_appends_args(self):
i = get_integration("kimi")
assert (
i.build_command_invocation("specify", "my feature")
== "/skill:speckit-specify my feature"
)
class TestKimiLegacySymlinkSafety:
"""Legacy migration/cleanup must not follow symlinks out of the project."""
def test_migrate_skips_symlinked_legacy_skills_dir(self, tmp_path):
# An attacker-controlled directory outside the project root. Use a
# non-template skill name so a successful migration would be visible
# (the bundled templates never create "speckit-evillegacy").
outside = tmp_path / "outside"
(outside / "speckit-evillegacy").mkdir(parents=True)
(outside / "speckit-evillegacy" / "SKILL.md").write_text("# evil\n")
project = tmp_path / "project"
(project / ".kimi").mkdir(parents=True)
# .kimi/skills is a symlink to the outside directory.
_symlink_or_skip(
project / ".kimi" / "skills", outside, target_is_directory=True
)
i = get_integration("kimi")
m = IntegrationManifest("kimi", project)
i.setup(project, m, parsed_options={"migrate_legacy": True})
# Outside content must be untouched (not moved into .kimi-code).
assert (outside / "speckit-evillegacy" / "SKILL.md").exists()
assert not (
project / ".kimi-code" / "skills" / "speckit-evillegacy"
).exists()
def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path):
outside = tmp_path / "outside"
outside.mkdir()
keep = outside / "keep.txt"
keep.write_text("important\n")
project = tmp_path / "project"
(project / ".kimi").mkdir(parents=True)
_symlink_or_skip(
project / ".kimi" / "skills", outside, target_is_directory=True
)
i = get_integration("kimi")
m = IntegrationManifest("kimi", project)
i.teardown(project, m)
# The symlink target and its contents must survive teardown.
assert keep.exists()
def test_migrate_skips_symlinked_legacy_parent_dir(self, tmp_path):
# `.kimi` is itself a symlink to the project root, so `.kimi/skills`
# resolves to `./skills` — an unrelated in-tree directory. Even though
# the resolved path stays inside the project, migration must not
# operate on it because a path component is a symlink.
project = tmp_path / "project"
unrelated = project / "skills" / "speckit-evillegacy"
unrelated.mkdir(parents=True)
(unrelated / "SKILL.md").write_text("# unrelated\n")
# .kimi -> project root, so .kimi/skills == ./skills.
_symlink_or_skip(project / ".kimi", project, target_is_directory=True)
i = get_integration("kimi")
m = IntegrationManifest("kimi", project)
i.setup(project, m, parsed_options={"migrate_legacy": True})
# The unrelated ./skills content must be untouched.
assert (unrelated / "SKILL.md").exists()
assert not (
project / ".kimi-code" / "skills" / "speckit-evillegacy"
).exists()
def test_teardown_skips_symlinked_legacy_parent_dir(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
# Looks Speckit-generated, so only the symlink check protects it.
unrelated = project / "skills" / "speckit-evillegacy"
unrelated.mkdir(parents=True)
(unrelated / "SKILL.md").write_text(
"---\nmetadata:\n author: github-spec-kit\n---\n# x\n"
)
_symlink_or_skip(project / ".kimi", project, target_is_directory=True)
i = get_integration("kimi")
m = IntegrationManifest("kimi", project)
i.teardown(project, m)
# The unrelated ./skills content must survive teardown.
assert (unrelated / "SKILL.md").exists()
def test_setup_rejects_symlinked_destination_before_writing(self, tmp_path):
# `.kimi-code` is a symlink to the project root, so the skills
# destination `.kimi-code/skills` resolves to `./skills` — an
# unintended in-tree location. base setup() only rejects a
# destination that escapes the project root, so without the
# pre-check it would write SKILL.md files into `./skills`. setup()
# must refuse before any write occurs.
project = tmp_path / "project"
project.mkdir()
_symlink_or_skip(project / ".kimi-code", project, target_is_directory=True)
i = get_integration("kimi")
m = IntegrationManifest("kimi", project)
with pytest.raises(ValueError, match="symlinked"):
i.setup(project, m)
# Nothing was written into the unintended `./skills` location.
assert not (project / "skills").exists()
def test_migrate_skips_symlinked_target_dir(self, tmp_path):
# The destination `.kimi-code/skills/speckit-foo` already exists but is
# a symlink to a directory outside the project. Migration compares
# SKILL.md bytes to decide whether to drop the legacy copy; it must not
# follow the symlinked target dir to read SKILL.md from outside.
outside = tmp_path / "outside"
outside.mkdir()
(outside / "SKILL.md").write_text("# shared\n")
project = tmp_path / "project"
legacy = project / ".kimi" / "skills" / "speckit-foo"
legacy.mkdir(parents=True)
# Identical bytes: without the symlink guard the legacy dir would be
# removed after following the link out of the project.
(legacy / "SKILL.md").write_text("# shared\n")
target = project / ".kimi-code" / "skills" / "speckit-foo"
target.parent.mkdir(parents=True)
_symlink_or_skip(target, outside, target_is_directory=True)
_migrate_legacy_kimi_skills_dir(
project / ".kimi" / "skills", project / ".kimi-code" / "skills"
)
# Legacy copy is preserved (migration refused to follow the symlink),
# and the outside target is untouched.
assert (legacy / "SKILL.md").exists()
assert (outside / "SKILL.md").exists()
def test_context_migration_does_not_write_through_symlinked_agents_md(
self, tmp_path
):
# A sensitive file outside the project that a malicious AGENTS.md
# symlink points at. Migration must never overwrite it.
outside = tmp_path / "outside"
outside.mkdir()
secret = outside / "secret.txt"
secret.write_text("original secret\n")
project = tmp_path / "project"
project.mkdir()
_symlink_or_skip(project / "AGENTS.md", secret)
(project / "KIMI.md").write_text("# Notes\n\nKeep this.\n")
result = _migrate_legacy_kimi_context_file(project)
# The outside file must not be overwritten through the symlink.
assert secret.read_text() == "original secret\n"
# KIMI.md is preserved so the user can migrate manually.
assert (project / "KIMI.md").is_file()
assert result is False
def test_context_migration_does_not_follow_symlinked_kimi_md(self, tmp_path):
# A symlinked KIMI.md (source) must not be followed/consumed.
outside = tmp_path / "outside"
outside.mkdir()
external = outside / "external.md"
external.write_text("# external\n")
project = tmp_path / "project"
project.mkdir()
_symlink_or_skip(project / "KIMI.md", external)
result = _migrate_legacy_kimi_context_file(project)
assert result is False
# The external file and the symlink are left intact.
assert external.read_text() == "# external\n"
assert (project / "KIMI.md").is_symlink()
assert not (project / "AGENTS.md").exists()
class TestKimiNextSteps:

View File

@@ -0,0 +1,31 @@
"""Tests for OmpIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestOmpIntegration(MarkdownIntegrationTests):
KEY = "omp"
FOLDER = ".omp/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".omp/commands"
CONTEXT_FILE = "AGENTS.md"
def test_build_exec_args_uses_omp_json_mode(self):
i = get_integration(self.KEY)
args = i.build_exec_args(
"/speckit.specify Build auth",
model="gpt-5",
)
assert args == [
"omp",
"--print",
"--model",
"gpt-5",
"--mode",
"json",
"/speckit.specify Build auth",
]

View File

@@ -15,19 +15,22 @@ from tests.conftest import strip_ansi
runner = CliRunner()
def _init_project(tmp_path, integration="copilot"):
def _init_project(tmp_path, integration="copilot", integration_options=None):
"""Helper: init a spec-kit project with the given integration."""
project = tmp_path / "proj"
project.mkdir()
args = [
"init", "--here",
"--integration", integration,
"--script", "sh",
"--ignore-agent-tools",
]
if integration_options:
args += ["--integration-options", integration_options]
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"init", "--here",
"--integration", integration,
"--script", "sh",
"--ignore-agent-tools",
], catch_exceptions=False)
result = runner.invoke(app, args, catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"init failed: {result.output}"
@@ -1237,6 +1240,137 @@ class TestIntegrationInstall:
assert "/speckit-specify" in script_content
assert "/speckit.specify" not in script_content
def test_install_defers_extension_commands_until_use(self, tmp_path):
"""Installing a second integration does not register enabled extensions.
Maintainer-requested behavior for #2886: extension command back-fill is
limited to ``integration use`` / ``switch`` / ``upgrade``. Plain
``install`` only adds the integration; selecting it with ``use`` then
registers the enabled extensions for that agent.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "claude" in registered
assert "codex" not in registered, "precondition: codex not yet installed"
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Install alone does not back-fill the git extension for the secondary
# agent.
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "claude" in registered, "existing agent registration preserved"
assert "codex" not in registered
assert not (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
result = _run_in_project(project, ["integration", "use", "codex"])
assert result.exit_code == 0, result.output
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" in registered, "use should register extension commands (#2886)"
assert (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
def test_install_does_not_register_disabled_extensions(self, tmp_path):
"""A disabled extension must not be registered for a newly installed agent."""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, ["extension", "disable", "git"])
assert result.exit_code == 0, result.output
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
registry_path = project / ".specify" / "extensions" / ".registry"
git_meta = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]
assert git_meta["enabled"] is False
assert "codex" not in git_meta["registered_commands"]
assert not (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
def test_install_skills_mode_secondary_agent_defers_extension_artifacts(self, tmp_path):
"""A non-active skills-mode agent gets extension artifacts only on use.
Plain ``install`` has no extension side effects. Once the secondary
Copilot ``--skills`` integration is selected with ``use``, it becomes the
active agent and receives extension skills.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
# Copilot is not multi_install_safe, so --force is required to add it
# alongside the existing default integration.
result = _run_in_project(project, [
"integration", "install", "copilot",
"--script", "sh",
"--integration-options", "--skills",
"--force",
])
assert result.exit_code == 0, result.output
# Precondition that makes --skills load-bearing: copilot IS in skills
# mode, so its own core commands are scaffolded as skills.
assert (
project / ".github" / "skills" / "speckit-specify" / "SKILL.md"
).exists(), "precondition: copilot installed in skills mode"
# The git extension is not registered for the non-active copilot agent
# during install.
git_meta = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)["extensions"]["git"]
assert "copilot" not in git_meta["registered_commands"]
assert not (
project / ".github" / "agents" / "speckit.git.feature.agent.md"
).exists()
assert not (
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
result = _run_in_project(project, ["integration", "use", "copilot"])
assert result.exit_code == 0, result.output
git_meta = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)["extensions"]["git"]
# `use` makes copilot active, so extension artifacts follow copilot's
# skills-mode layout.
assert "copilot" not in git_meta["registered_commands"]
assert "speckit-git-feature" in git_meta["registered_skills"]
assert not (
project / ".github" / "agents" / "speckit.git.feature.agent.md"
).exists()
assert (
project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
).exists()
# ── uninstall ────────────────────────────────────────────────────────
@@ -1678,7 +1812,7 @@ class TestIntegrationSwitch:
assert result.exit_code == 0, f"extension add failed: {result.output}"
# Verify git extension skills exist for kimi
kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md"
kimi_git_feature = project / ".kimi-code" / "skills" / "speckit-git-feature" / "SKILL.md"
assert kimi_git_feature.exists(), "Git extension skill should exist for kimi"
result = _run_in_project(project, [
@@ -1724,6 +1858,40 @@ class TestIntegrationSwitch:
assert "claude" in registered_commands
assert "opencode" not in registered_commands
def test_switch_installed_target_backfills_extension_commands(self, tmp_path):
"""Switching to an already-installed agent should register extensions."""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "claude" in registered
assert "codex" not in registered, "precondition: codex not yet installed"
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
codex_git_feature = (
project / ".agents" / "skills" / "speckit-git-feature" / "SKILL.md"
)
assert not codex_git_feature.exists()
result = _run_in_project(project, ["integration", "switch", "codex"])
assert result.exit_code == 0, result.output
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" in registered
assert codex_git_feature.exists()
def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path):
"""Copilot --skills should receive extension skills, not .agent.md files."""
project = _init_project(tmp_path, "opencode")
@@ -2324,6 +2492,93 @@ class TestIntegrationUpgrade:
"shared .sh scripts must be executable after upgrade"
)
def test_upgrade_backfills_extension_commands_for_agent(self, tmp_path):
"""Upgrade re-registers enabled extensions for the upgraded agent.
Regression for #2886: agents installed before extension back-fill
existed (or whose extension artifacts went missing) should regain the
enabled extensions' commands on ``upgrade``, reaching parity with
``switch``.
"""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, [
"integration", "install", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Simulate a project created before the install/upgrade back-fill: drop
# codex's extension registration and its rendered artifacts.
registry_path = project / ".specify" / "extensions" / ".registry"
registry = json.loads(registry_path.read_text(encoding="utf-8"))
registry["extensions"]["git"]["registered_commands"].pop("codex", None)
registry_path.write_text(json.dumps(registry), encoding="utf-8")
agents_skills = project / ".agents" / "skills"
for skill_dir in agents_skills.glob("speckit-git-*"):
shutil.rmtree(skill_dir)
# Precondition: codex is now missing the git extension.
assert "codex" not in json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert not (agents_skills / "speckit-git-feature" / "SKILL.md").exists()
result = _run_in_project(project, [
"integration", "upgrade", "codex",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Upgrade back-filled the git extension for codex.
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "codex" in registered, "upgrade should re-register extension commands (#2886)"
assert (agents_skills / "speckit-git-feature" / "SKILL.md").exists()
def test_upgrade_non_active_agent_preserves_active_agent_skills(self, tmp_path):
"""Upgrading a non-active agent must not touch the active agent's skills.
Regression for the #2886 wiring: extension skill rendering is
active-agent-scoped, so routing upgrade of a *secondary* agent through
``register_enabled_extensions_for_agent`` used to re-render the
*active* skills-mode agent's extension skills as a side effect —
resurrecting skill files the user had deliberately deleted. The skills
pass is now gated on the target being the active agent. (Skills parity
for non-active agents is tracked separately in #2948.)
"""
# Active agent: copilot in skills mode → git extension renders as skills.
project = _init_project(tmp_path, "copilot", integration_options="--skills")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
assert skill.exists(), "precondition: active copilot has the git extension skill"
# Add a secondary (non-active) agent; copilot is not multi_install_safe.
result = _run_in_project(project, [
"integration", "install", "codex", "--script", "sh", "--force",
])
assert result.exit_code == 0, result.output
# The user deliberately removes the active agent's git skill.
shutil.rmtree(skill.parent)
assert not skill.exists()
# Upgrading the *non-active* agent must not re-render copilot's skills.
result = _run_in_project(project, [
"integration", "upgrade", "codex", "--script", "sh",
])
assert result.exit_code == 0, result.output
assert not skill.exists(), (
"upgrading a non-active agent must not resurrect the active agent's "
"deleted extension skill (#2886)"
)
# ── Full lifecycle ───────────────────────────────────────────────────

View File

@@ -0,0 +1,38 @@
"""Tests for ZcodeIntegration — skills-based integration (Z.AI)."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestZcodeIntegration(SkillsIntegrationTests):
KEY = "zcode"
FOLDER = ".zcode/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".zcode/skills"
CONTEXT_FILE = "ZCODE.md"
class TestZcodeInvocation:
"""ZCode renders $speckit-* chat invocations (like Codex)."""
def test_next_steps_show_dollar_skill_invocation(self, tmp_path):
"""ZCode next-steps guidance should display $speckit-* usage."""
import os
from typer.testing import CliRunner
from specify_cli import app
project = tmp_path / "zcode-next-steps"
project.mkdir()
old_cwd = os.getcwd()
try:
os.chdir(project)
runner = CliRunner()
result = runner.invoke(app, [
"init", "--here", "--integration", "zcode",
"--ignore-agent-tools", "--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "$speckit-constitution" in result.output
assert "/speckit.constitution" not in result.output

View File

@@ -116,6 +116,34 @@ class TestManifestPathTraversal:
assert len(removed) == 1
assert removed[0].name == "safe.txt"
def test_remove_drops_entry_and_is_noop_second_time(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
assert "f.txt" in m.files
assert m.remove("f.txt") is True
assert "f.txt" not in m.files
assert m.remove("f.txt") is False # already gone → no-op
def test_remove_rejects_absolute_path(self, tmp_path):
# Matches record_existing/is_recovered: an absolute key can never be a
# canonical manifest key, so remove() rejects it lexically and leaves
# the tracked entry untouched.
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
import sys
abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt"
assert m.remove(abs_input) is False
assert "f.txt" in m.files
def test_remove_rejects_parent_traversal(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
assert m.remove("../f.txt") is False
assert "f.txt" in m.files
class TestManifestCheckModified:
def test_unmodified_file(self, tmp_path):

View File

@@ -23,7 +23,7 @@ ALL_INTEGRATION_KEYS = [
# Stage 3 — standard markdown integrations
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
"roo", "rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent",
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent", "firebender",
# Stage 4 — TOML integrations
"gemini", "tabnine",
# Stage 5 — skills, generic & option-driven integrations

View File

@@ -1,15 +1,159 @@
"""Consistency checks for agent configuration across runtime surfaces."""
import re
from pathlib import Path
import yaml
from specify_cli import AGENT_CONFIG
from specify_cli.extensions import CommandRegistrar
REPO_ROOT = Path(__file__).resolve().parent.parent
ISSUE_TEMPLATE_AGENT_KEYS = [
"amp",
"agy",
"auggie",
"claude",
"cline",
"codebuddy",
"codex",
"cursor-agent",
"devin",
"firebender",
"forge",
"gemini",
"copilot",
"goose",
"hermes",
"bob",
"iflow",
"junie",
"kilocode",
"kimi",
"kiro-cli",
"lingma",
"vibe",
"omp",
"opencode",
"pi",
"qodercli",
"qwen",
"roo",
"rovodev",
"shai",
"tabnine",
"trae",
"windsurf",
"zcode",
"zed",
]
def _issue_template(path: str) -> dict:
return yaml.safe_load((REPO_ROOT / path).read_text(encoding="utf-8"))
def _body_item_by_id(template: dict, item_id: str) -> dict:
for item in template["body"]:
if item.get("id") == item_id:
return item
raise AssertionError(f"Expected issue template body item {item_id!r}")
def _dropdown_options(path: str, item_id: str) -> list[str]:
item = _body_item_by_id(_issue_template(path), item_id)
return item["attributes"]["options"]
def _normalized_markdown(text: str) -> str:
return " ".join(text.split())
def _markdown_value_containing(path: str, marker: str) -> str:
template = _issue_template(path)
normalized_marker = _normalized_markdown(marker)
for item in template["body"]:
if item.get("type") != "markdown":
continue
value = item["attributes"]["value"]
if normalized_marker in _normalized_markdown(value):
return value
raise AssertionError(f"Expected issue template markdown containing {marker!r}")
def _markdown_paragraph_containing(path: str, marker: str) -> str:
value = _markdown_value_containing(path, marker)
normalized_marker = _normalized_markdown(marker)
for paragraph in re.split(r"\n\s*\n", value):
if normalized_marker in _normalized_markdown(paragraph):
return paragraph
raise AssertionError(f"Expected issue template paragraph containing {marker!r}")
def _supported_agent_names_from_agent_request_template() -> list[str]:
marker = "**Currently supported agents**:"
paragraph = _markdown_paragraph_containing(
".github/ISSUE_TEMPLATE/agent_request.yml",
marker,
)
supported_agents_text = _normalized_markdown(paragraph).split(marker, 1)[1].strip()
return [agent.strip() for agent in supported_agents_text.split(",")]
class TestAgentConfigConsistency:
"""Ensure kiro-cli migration stays synchronized across key surfaces."""
"""Ensure agent configuration stays synchronized across key surfaces."""
def test_issue_template_agent_lists_match_runtime_integrations(self):
"""GitHub issue templates should list all concrete built-in agents."""
concrete_agent_keys = set(AGENT_CONFIG) - {"generic"}
issue_template_agent_keys = set(ISSUE_TEMPLATE_AGENT_KEYS)
missing_agent_keys = sorted(concrete_agent_keys - issue_template_agent_keys)
unexpected_agent_keys = sorted(issue_template_agent_keys - concrete_agent_keys)
duplicate_agent_keys = sorted(
key
for key in issue_template_agent_keys
if ISSUE_TEMPLATE_AGENT_KEYS.count(key) > 1
)
assert not missing_agent_keys, (
"Issue template agent list is missing AGENT_CONFIG keys: "
f"{missing_agent_keys}"
)
assert not unexpected_agent_keys, (
"Issue template agent list includes unknown AGENT_CONFIG keys: "
f"{unexpected_agent_keys}"
)
assert not duplicate_agent_keys, (
"Issue template agent list contains duplicate keys: "
f"{duplicate_agent_keys}"
)
issue_template_agent_names = [
AGENT_CONFIG[key]["name"] for key in ISSUE_TEMPLATE_AGENT_KEYS
]
assert "Generic (bring your own agent)" not in issue_template_agent_names
bug_options = _dropdown_options(
".github/ISSUE_TEMPLATE/bug_report.yml",
"ai-agent",
)
assert bug_options == issue_template_agent_names + ["Not applicable"]
feature_options = _dropdown_options(
".github/ISSUE_TEMPLATE/feature_request.yml",
"ai-agent",
)
assert feature_options == [
"All agents",
*issue_template_agent_names,
"Not applicable",
]
assert (
_supported_agent_names_from_agent_request_template()
== issue_template_agent_names
)
def test_runtime_config_uses_kiro_cli_and_removes_q(self):
"""AGENT_CONFIG should include kiro-cli and exclude legacy q."""
@@ -82,17 +226,17 @@ class TestAgentConfigConsistency:
def test_kimi_in_agent_config(self):
"""AGENT_CONFIG should include kimi with correct folder and commands_subdir."""
assert "kimi" in AGENT_CONFIG
assert AGENT_CONFIG["kimi"]["folder"] == ".kimi/"
assert AGENT_CONFIG["kimi"]["folder"] == ".kimi-code/"
assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills"
assert AGENT_CONFIG["kimi"]["requires_cli"] is True
def test_kimi_in_extension_registrar(self):
"""Extension command registrar should include kimi using .kimi/skills and SKILL.md."""
"""Extension command registrar should include kimi using .kimi-code/skills and SKILL.md."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert "kimi" in cfg
kimi_cfg = cfg["kimi"]
assert kimi_cfg["dir"] == ".kimi/skills"
assert kimi_cfg["dir"] == ".kimi-code/skills"
assert kimi_cfg["extension"] == "/SKILL.md"
def test_agent_config_includes_kimi(self):
@@ -217,6 +361,12 @@ class TestAgentConfigConsistency:
"expected '-' (propagated from SkillsIntegration.invoke_separator)"
)
def test_codex_dev_no_symlink_policy_in_agent_config(self):
"""Codex dev installs must expose the no-symlink policy as metadata."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert cfg["codex"].get("dev_no_symlink") is True
def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path):
"""__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit-<cmd>
when registered for a skills-based agent (e.g. claude).

View File

@@ -900,3 +900,45 @@ class TestFetchLatestReleaseTagDelegation:
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
_fetch_latest_release_tag()
assert captured["request"].get_header("Accept") == "application/vnd.github+json"
# ---------------------------------------------------------------------------
# github_provider_hosts
# ---------------------------------------------------------------------------
class TestGithubProviderHosts:
"""Tests for github_provider_hosts() — the GHES host allowlist source."""
def _set_config(self, monkeypatch, entries):
from specify_cli.authentication import http as _auth_http
monkeypatch.setattr(_auth_http, "_config_override", entries)
def test_returns_hosts_from_github_entries(self, monkeypatch):
from specify_cli.authentication.http import github_provider_hosts
self._set_config(monkeypatch, [
AuthConfigEntry(hosts=("ghes.example", "raw.ghes.example"),
provider="github", auth="bearer", token="t"),
])
assert github_provider_hosts() == ("ghes.example", "raw.ghes.example")
def test_empty_when_no_config(self, monkeypatch):
from specify_cli.authentication.http import github_provider_hosts
self._set_config(monkeypatch, [])
assert github_provider_hosts() == ()
def test_ignores_non_github_providers(self, monkeypatch):
from specify_cli.authentication.http import github_provider_hosts
self._set_config(monkeypatch, [
AuthConfigEntry(hosts=("dev.azure.com",), provider="azure-devops",
auth="basic-pat", token="t"),
])
assert github_provider_hosts() == ()
def test_unions_multiple_github_entries(self, monkeypatch):
from specify_cli.authentication.http import github_provider_hosts
self._set_config(monkeypatch, [
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
AuthConfigEntry(hosts=("github.com",), provider="github", auth="bearer", token="t"),
])
assert github_provider_hosts() == ("ghes.example", "github.com")

View File

@@ -143,7 +143,11 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
@requires_bash
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without --paths-only, feature directory validation must still fail on main."""
"""Without --paths-only, feature directory validation must still fail on main.
The error must go to stderr and stdout must stay clean, so a caller that
parses stdout as JSON is not handed the error string instead (#3122).
"""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json"],
@@ -155,6 +159,8 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert "Feature directory not found" not in result.stdout
assert result.stdout.strip() == ""
# ── PowerShell tests ──────────────────────────────────────────────────────
@@ -213,7 +219,11 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without -PathsOnly, feature directory validation must still fail on main."""
"""Without -PathsOnly, feature directory validation must still fail on main.
The error must land on stderr only, leaving stdout clean for -Json
callers that parse it as JSON (#3122).
"""
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
@@ -225,5 +235,51 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
env=_clean_env(),
)
assert result.returncode != 0
combined = result.stdout + result.stderr
assert "Feature directory not found" in combined
assert "Feature directory not found" in result.stderr
assert "Feature directory not found" not in result.stdout
assert result.stdout.strip() == ""
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_missing_plan_error_goes_to_stderr(prereq_repo: Path) -> None:
"""A missing plan.md must report on stderr, not stdout (#3122)."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "plan.md not found" in result.stderr
assert "plan.md not found" not in result.stdout
assert result.stdout.strip() == ""
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_missing_tasks_error_goes_to_stderr(prereq_repo: Path) -> None:
"""With -RequireTasks, a missing tasks.md must report on stderr only (#3122)."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-RequireTasks"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "tasks.md not found" in result.stderr
assert "tasks.md not found" not in result.stdout
assert result.stdout.strip() == ""

View File

@@ -573,6 +573,84 @@ class TestExtensionSkillRegistration:
assert "speckit-test-ext-hello" in written
assert "Run this updated hello." in skill_file.read_text(encoding="utf-8")
def test_codex_dev_skill_registration_replaces_existing_dev_symlink(
self, project_dir, extension_dir, temp_dir
):
"""Codex dev skill registration should migrate prior dev symlinks to files."""
if not _can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
_create_init_options(project_dir, ai="codex", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="codex")
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
skill_file.parent.mkdir(parents=True, exist_ok=True)
cache_file = (
extension_dir
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
cache_file.parent.mkdir(parents=True)
cache_file.write_text("old linked content", encoding="utf-8")
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
written = manager._register_extension_skills(
manifest,
extension_dir,
link_outputs=True,
)
assert "speckit-test-ext-hello" in written
assert skill_file.exists()
assert not skill_file.is_symlink()
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
assert cache_file.read_text(encoding="utf-8") == "old linked content"
def test_codex_dev_skill_registration_preserves_unrelated_symlink(
self, project_dir, extension_dir, temp_dir
):
"""Codex dev registration should not overwrite user-owned symlinks."""
if not _can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
_create_init_options(project_dir, ai="codex", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="codex")
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
skill_file.parent.mkdir(parents=True, exist_ok=True)
unrelated_cache_file = (
temp_dir
/ "other-extension"
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
unrelated_cache_file.parent.mkdir(parents=True)
unrelated_cache_file.write_text("user-owned linked content", encoding="utf-8")
os.symlink(
os.path.relpath(unrelated_cache_file, skill_file.parent), skill_file
)
written = manager._register_extension_skills(
manifest,
extension_dir,
link_outputs=True,
)
assert "speckit-test-ext-hello" not in written
assert skill_file.is_symlink()
assert skill_file.resolve(strict=True) == unrelated_cache_file.resolve()
assert unrelated_cache_file.read_text(encoding="utf-8") == (
"user-owned linked content"
)
def test_dev_skill_registration_falls_back_to_copy_when_symlink_fails(
self, skills_project, extension_dir, monkeypatch
):

Some files were not shown because too many files have changed in this diff Show More