mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0687f4b46 |
@@ -22,11 +22,11 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0
|
||||
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
|
||||
@@ -260,13 +260,13 @@ The base classes handle most work automatically. Override only when the agent de
|
||||
| Override | When to use | Example |
|
||||
|---|---|---|
|
||||
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
|
||||
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--commands` flag |
|
||||
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `speckit-<name>/SKILL.md` (default) or `.agent.md` + `.prompt.md` + `.vscode/settings.json` (`--commands`) |
|
||||
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
|
||||
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
|
||||
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
|
||||
|
||||
**Example — Copilot (fully custom `setup`):**
|
||||
|
||||
Copilot extends `IntegrationBase` directly because it supports two layouts. It scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` by default using composition with an internal `_CopilotSkillsHelper`. Its `--commands` mode creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
|
||||
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
|
||||
|
||||
### 7. Update Devcontainer files (Optional)
|
||||
|
||||
@@ -415,28 +415,36 @@ Some agents require custom processing beyond the standard template transformatio
|
||||
|
||||
### Copilot Integration
|
||||
|
||||
GitHub Copilot uses skills by default, scaffolded as
|
||||
`speckit-<name>/SKILL.md` under `.github/skills/`.
|
||||
GitHub Copilot has unique requirements:
|
||||
|
||||
**Commands mode (`--commands`):** Copilot also supports a commands-based layout
|
||||
via `--integration-options="--commands"`. When enabled:
|
||||
|
||||
- Commands use `.agent.md` extension under `.github/agents/`
|
||||
- Commands use `.agent.md` extension (not `.md`)
|
||||
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
|
||||
- `.vscode/settings.json` is merged with prompt file recommendations
|
||||
- `build_command_invocation()` returns bare args for `--agent` dispatch
|
||||
- Installs `.vscode/settings.json` with prompt file recommendations
|
||||
- Context file lives at `.github/copilot-instructions.md`
|
||||
|
||||
In the default skills mode, no companion prompts or VS Code settings merge are
|
||||
created, and `build_command_invocation()` returns `/speckit-<stem>`.
|
||||
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
|
||||
|
||||
1. Processes templates with `process_template()`
|
||||
2. Generates companion `.prompt.md` files
|
||||
3. Merges VS Code settings
|
||||
|
||||
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
|
||||
via `--integration-options="--skills"`. When enabled:
|
||||
|
||||
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
|
||||
- No companion `.prompt.md` files are generated
|
||||
- No `.vscode/settings.json` merge
|
||||
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
|
||||
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
|
||||
|
||||
The two modes are mutually exclusive — a project uses one or the other:
|
||||
|
||||
```bash
|
||||
# Default skills mode: speckit-<name>/SKILL.md under .github/skills/
|
||||
# Default mode: .agent.md agents + .prompt.md companions + settings merge
|
||||
specify init my-project --integration copilot
|
||||
|
||||
# Commands mode: .agent.md agents + .prompt.md companions + settings merge
|
||||
specify init my-project --integration copilot --integration-options="--commands"
|
||||
# Skills mode: speckit-<name>/SKILL.md under .github/skills/
|
||||
specify init my-project --integration copilot --integration-options="--skills"
|
||||
```
|
||||
|
||||
### Forge Integration
|
||||
@@ -501,11 +509,6 @@ When an issue exists, include its number immediately after the prefix — this i
|
||||
|
||||
Disclosure is **continuous**, not a one-time event. A single AI-disclosure paragraph in the PR body does **not** cover the commits and replies you add during review rounds. Each of the following must independently attest to agent authorship.
|
||||
|
||||
### Opening pull requests
|
||||
|
||||
- Before opening a pull request, check whether the account that will file it already has three open pull requests in this repository.
|
||||
- If so, alert the user that additional submissions may receive lower review priority and ask for explicit permission to proceed. Do not assume consent.
|
||||
|
||||
### Commits
|
||||
|
||||
- **Every commit you author must carry an `Assisted-by:` trailer** identifying the agent and whether it acted autonomously or under direct human supervision, for example:
|
||||
|
||||
@@ -2,57 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.16.1] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(integrations): wrap a non-UTF-8 catalog response (#4011)
|
||||
- fix(events): skip an unreadable command template (#3956)
|
||||
- fix(bundle): wrap malformed YAML in a local .zip bundle manifest (#4013)
|
||||
- chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 (#4005)
|
||||
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#4006)
|
||||
- fix(agent-context): recurse for nested plans in Python mtime fallback (#3757)
|
||||
- fix: add utf-8 encoding to extension and preset registry file I/O (#3834)
|
||||
- fix(init): escape user-supplied values in `specify init` output (#3787)
|
||||
- fix: bound response read in integration catalog fetch (#3812)
|
||||
- fix: use missing_ok for temp file cleanup to avoid masking errors (#3803)
|
||||
- fix(workflows): handle an unreadable run state in `workflow status` (#3999)
|
||||
- feat(init): scaffold managed .specify/.gitignore (#4000)
|
||||
- fix(scripts): stop check-prerequisites text mode crashing on a legacy stdout code page (#3890)
|
||||
- fix(presets): return None for an unreadable layer in resolve_content (#3959)
|
||||
- fix(extensions): start fresh on a non-UTF-8 extension registry (#3998)
|
||||
- Fix init-force-preset-desync: reapply presets/extensions on init --here --force (#3995)
|
||||
- fix(skills): apply the line-anchored delimiter scan to hermes and kimi (#3739)
|
||||
- fix(archives): wrap the bare EOFError a truncated tar.gz raises (#3938)
|
||||
- test(integrations): guard multiline/control-char SKILL.md frontmatter escaping (#3392)
|
||||
- fix(scripts): stop setup-tasks text mode crashing on a legacy code page (#3892)
|
||||
- chore: release 0.16.0, begin 0.16.1.dev0 development (#3992)
|
||||
|
||||
## [0.16.0] - 2026-08-05
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: keep long frontmatter values on a single line (#3989)
|
||||
- fix: skip corrupted run state files in list_runs (#3817)
|
||||
- fix: skip corrupted run state files in list_runs (#3814)
|
||||
- Add July 2026 newsletter (#3987)
|
||||
- fix(presets): start fresh on a non-UTF-8 preset registry (#3955)
|
||||
- docs: clarify agent PR review prioritization (#3985)
|
||||
- fix(events): preserve a non-UTF-8 config.toml on hook install/teardown (#3963)
|
||||
- fix(extensions): treat an unreadable staged backup as a conflict (#3962)
|
||||
- fix(manifests): reject non-string requires.speckit_version (#3980)
|
||||
- fix(extensions): reject reinstall when a kept config cannot be read (#3960)
|
||||
- [extension] Update Charter extension to v0.5.1 (#3983)
|
||||
- fix(events): return None for an unparseable script command (#3957)
|
||||
- feat(events): context injection for opencode and JSON-envelope agent hooks (#3934)
|
||||
- Add TDD Extension to community catalog (#3982)
|
||||
- Update Archive Extension to v1.1.0 (#3981)
|
||||
- feat(copilot): default integration to skills (#3976)
|
||||
- fix(events): ignore non-UTF-8 event overrides (#3897)
|
||||
- fix: cap stdin read at 1 MiB to prevent DoS (#3857)
|
||||
- fix(workflows): reject mismatched run state IDs (#3899)
|
||||
- chore: release 0.15.2, begin 0.15.3.dev0 development (#3953)
|
||||
|
||||
## [0.15.2] - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -55,8 +55,6 @@ Here are a few things you can do that will increase the likelihood of your pull
|
||||
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
|
||||
- Test your changes with the Spec-Driven Development workflow to ensure compatibility.
|
||||
|
||||
Accounts with three open pull requests may continue submitting changes, but additional submissions may be placed behind contributions from other authors in the review queue. Coding agents should disclose this possibility and obtain the filer's confirmation before opening another pull request.
|
||||
|
||||
### Branch naming
|
||||
|
||||
We recommend naming branches as `<type>/<number>-<short-slug>`, where `<number>` is the issue or PR number (whichever comes first) and `<type>` is one of:
|
||||
|
||||
@@ -153,7 +153,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
|
||||
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
|
||||
| TDD Extension | Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength. | `process` | Read+Write | [spec-kit-tdd](https://github.com/d0whc3r/spec-kit-tdd) |
|
||||
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
|
||||
| Test Coverage Drift Control | Generate incremental coverage drift reports and planned remediation tasks after implementation | `code` | Read+Write | [spec-kit-test-coverage-drift-control](https://github.com/benizzio/spec-kit-test-coverage-drift-control) |
|
||||
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
|
||||
|
||||
@@ -57,8 +57,6 @@ specify init my-project --integration copilot --preset compliance
|
||||
|
||||
> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature.
|
||||
|
||||
> **Version control.** `specify init` scaffolds a managed `.specify/.gitignore` that excludes machine-local state — `feature.json` (the current-feature pointer, rewritten on every feature switch) and per-machine extension `extensions/*/local-config.yml` overrides — while leaving everything else under `.specify/` (constitution, templates, scripts, extension config) shareable so teams stay aligned. Like the rest of `.specify/`'s shared scripts and templates, the file is tracked in the shared-infrastructure manifest: your edits are preserved on re-init and `specify init --here --force` restores the managed content. It is intentionally left in place by `specify integration uninstall`, which only removes the uninstalled agent's own files.
|
||||
|
||||
> **Symlinked project roots.** `SPECIFY_INIT_DIR` relocates *where* the project is, not *how* a command treats symlinks: each command keeps its existing cwd-path stance. Commands that traverse and write project files through broad input paths (`bundle`, `workflow run <file>`) refuse a symlinked `.specify/` to preserve write confinement. Other project-scoped commands keep their existing behavior when `SPECIFY_INIT_DIR` points at a project root, which may include following a symlinked `.specify/`.
|
||||
|
||||
## Check Installed Tools
|
||||
|
||||
@@ -20,7 +20,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [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` | Skills-based by default; installs `speckit-<command>/SKILL.md` under `.github/skills/`. Pass `--integration-options="--commands"` to use the supported commands layout: `.agent.md` files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
|
||||
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
|
||||
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
|
||||
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
|
||||
@@ -234,8 +234,7 @@ Some integrations accept additional options via `--integration-options`:
|
||||
| ----------- | ------------------- | -------------------------------------------------------------- |
|
||||
| `generic` | `--commands-dir` | Required. Directory for command files |
|
||||
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
|
||||
| `copilot` | `--commands` | Scaffold `.github/agents/*.agent.md` commands with `.github/prompts/*.prompt.md` companions and merge `.vscode/settings.json` instead of using the default skills layout. |
|
||||
| `copilot` | `--skills` | Force the default skills layout, overriding an existing commands layout during an explicit migration. |
|
||||
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ Usage: update_agent_context.py [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 ``plan.md`` found anywhere under ``specs/`` — scoped layouts
|
||||
nest it as ``specs/<scope>/<feature>/plan.md`` — only when feature.json is
|
||||
recently modified ``plan.md`` anywhere under ``specs/`` (including nested scoped
|
||||
layouts such as ``specs/<scope>/<feature>/plan.md``) only when feature.json is
|
||||
absent or its plan does not exist yet.
|
||||
"""
|
||||
|
||||
@@ -173,31 +173,16 @@ def _resolve_plan_path(project_root: str) -> str:
|
||||
|
||||
if not plan_path:
|
||||
root = Path(project_root).resolve()
|
||||
specs = root / "specs"
|
||||
|
||||
def _resolved_rel(p: Path) -> Path | None:
|
||||
# Resolve symlinks before checking containment: relative_to() is
|
||||
# lexical and would otherwise accept a plan reached through a specs/
|
||||
# symlink that points outside the project, emitting an
|
||||
# in-project-looking path for an out-of-project file (or picking it
|
||||
# as "most recent").
|
||||
plans = sorted(
|
||||
(root / "specs").rglob("plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if plans:
|
||||
try:
|
||||
return p.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
# Recurse (rather than the old one-level specs/*/plan.md glob) so scoped
|
||||
# layouts created via SPECIFY_FEATURE_DIRECTORY, e.g.
|
||||
# specs/<scope>/<feature>/plan.md, are still discovered when
|
||||
# feature.json is absent (#3024). Mirrors the bash and PowerShell twins.
|
||||
candidates = []
|
||||
for p in specs.rglob("plan.md"):
|
||||
rel = _resolved_rel(p)
|
||||
if rel is not None:
|
||||
candidates.append((p, rel))
|
||||
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
|
||||
if candidates:
|
||||
plan_path = candidates[0][1].as_posix()
|
||||
plan_path = plans[0].relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
plan_path = ""
|
||||
return plan_path
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-08-04T00:00:00Z",
|
||||
"updated_at": "2026-08-03T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"adrkit": {
|
||||
@@ -362,8 +362,8 @@
|
||||
"id": "archive",
|
||||
"description": "Archive merged features into main project memory, resolving gaps and conflicts.",
|
||||
"author": "Stanislav Deviatov",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.1.0.zip",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/stn1slv/spec-kit-archive",
|
||||
"homepage": "https://github.com/stn1slv/spec-kit-archive",
|
||||
"documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md",
|
||||
@@ -388,7 +388,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z"
|
||||
"updated_at": "2026-03-14T00:00:00Z"
|
||||
},
|
||||
"azure-devops": {
|
||||
"name": "Azure DevOps Integration",
|
||||
@@ -811,8 +811,8 @@
|
||||
"id": "charter",
|
||||
"description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
|
||||
"author": "Fyloss",
|
||||
"version": "0.5.1",
|
||||
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.5.1.zip",
|
||||
"version": "0.3.1",
|
||||
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.3.1.zip",
|
||||
"repository": "https://github.com/Fyloss/spec-kit-charter",
|
||||
"homepage": "https://github.com/Fyloss/spec-kit-charter",
|
||||
"documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
|
||||
@@ -821,8 +821,7 @@
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.11.9",
|
||||
"tools": [{ "name": "git", "required": false }]
|
||||
"speckit_version": ">=0.11.9"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
@@ -839,7 +838,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-06T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z"
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"ci-guard": {
|
||||
"name": "CI Guard",
|
||||
@@ -4489,47 +4488,6 @@
|
||||
"created_at": "2026-06-22T00:00:00Z",
|
||||
"updated_at": "2026-06-22T00:00:00Z"
|
||||
},
|
||||
"tdd": {
|
||||
"name": "TDD Extension",
|
||||
"id": "tdd",
|
||||
"description": "Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength.",
|
||||
"author": "d0whc3r",
|
||||
"version": "1.1.2",
|
||||
"download_url": "https://github.com/d0whc3r/spec-kit-tdd/releases/download/v1.1.2/tdd-1.1.2.zip",
|
||||
"repository": "https://github.com/d0whc3r/spec-kit-tdd",
|
||||
"homepage": "https://d0whc3r.github.io/spec-kit-tdd/",
|
||||
"documentation": "https://github.com/d0whc3r/spec-kit-tdd/wiki",
|
||||
"changelog": "https://github.com/d0whc3r/spec-kit-tdd/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.11.9"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"acceptance-tests",
|
||||
"mutation-testing",
|
||||
"property-based-testing",
|
||||
"quality",
|
||||
"red-green-refactor",
|
||||
"spec-kit",
|
||||
"spec-kit-extension",
|
||||
"tdd",
|
||||
"test-driven-development",
|
||||
"test-first",
|
||||
"testing",
|
||||
"unit-tests"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-04T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z"
|
||||
},
|
||||
"team-assign": {
|
||||
"name": "Team Assign",
|
||||
"id": "team-assign",
|
||||
|
||||
@@ -4,7 +4,7 @@ This edition covers Spec Kit activity in April 2026. Seventeen releases shipped
|
||||
|
||||
| **Spec Kit Core (Apr 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. External coverage continued across developer blogs and industry press. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
|
||||
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. 12 substantive external articles published. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
|
||||
|
||||
***
|
||||
|
||||
@@ -94,7 +94,7 @@ On **April 15**, the **Thoughtworks Technology Radar Volume 34** placed GitHub S
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
April produced a steady stream of external articles.
|
||||
April produced 12 substantive external articles (plus one excluded as AI-generated SEO spam).
|
||||
|
||||
**Matt Rickard** published *"The Spec Layer: Why Spec-Driven Development (SDD) Works"* on April 1. His thesis: specs reduce execution freedom for AI agents, functioning as constraint surfaces. He compared Spec Kit, Kiro, OpenSpec, Tessl, Intent, and Symphony, and advocated for **"smaller specs, harder checks, less guessing."** [\[blog.matt-rickard.com\]](https://blog.matt-rickard.com/p/the-spec-layer)
|
||||
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
# Spec Kit - July 2026 Newsletter
|
||||
|
||||
This edition covers Spec Kit activity in July 2026 — a month of hardening and expanding the envelope. Twenty-eight releases shipped (v0.12.3 through v0.15.1), crossing three minor bumps and delivering three headline capabilities: the **`assess` "Idea Assessment Pipeline" extension**, which pushes spec-driven development *upstream* of the spec to answer "should we even build this?"; the new **`py` (Python) script type** and the broad shell→Python port that underpins it; and a **first-class agent-native runtime events layer** for integrations. Beneath the features, the month's dominant engineering theme was a sustained **security-hardening wave** — bounded HTTP reads, strict redirect validation, TOCTOU-race elimination, and defensive validation across the workflow engine. Externally, coverage broadened structurally: mainstream tech press (heise online) covered the v0.13 `assess` release in two languages, and a **companion-tooling ecosystem** bloomed around the project — spec↔code drift detectors, model-sizing advisors, and testing-gap tools all built *on top of* Spec Kit. A summary is in the table below, followed by details.
|
||||
|
||||
| **Spec Kit Core (Jul 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Twenty-eight releases shipped (v0.12.3–v0.15.1), crossing v0.13, v0.14, and v0.15. Headline features: the `assess` **Idea Assessment Pipeline** extension (capture→evidence→refine→design→go/clarify/kill), the new **`py` script type** plus a shell→Python port of the core scripts, git extension, and agent-context updater, and an **agent-native runtime events layer** for integrations. Three agents joined (Grok Build, Factory Droid CLI, Alquimia AI), the label-driven **bug-fix/bug-test** automation completed the triage pipeline, and a heavy **security-hardening** wave landed. The repo grew from ~117,400 to **124,655 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 125 to **144 entries**; presets reached **29**, community workflows **2**, bundles **1**. **heise online** covered the v0.13 `assess` release in English and German. Coverage shifted toward comparisons, companion tooling, and "who verifies the spec?" critiques. **~258 contributors** now listed. | A **companion-tooling ecosystem** emerged — artgraph (deterministic spec↔code drift), SpecJudge (model right-sizing), GAUNTLEX (security-testing gap), and custom skills like `speckit-next` and `prefill`. Comparisons increasingly pit Spec Kit against Kiro; balanced reviews keep flagging documentation proliferation and cognitive load, precisely the gaps the `assess` upstream step and the drift/companion ecosystem are built to close. |
|
||||
|
||||
***
|
||||
|
||||
> **Hardening the Foundation, Expanding the Envelope.** If June was defined by external validation, July was defined by internal consolidation and reach. No single release carried the weight of `converge` or `bundle`, but the month moved the project in two directions at once. It reached *upstream* — the new `assess` pipeline lets a team evaluate an idea (capture evidence, refine, design, then go/clarify/kill) *before* a spec exists, extending SDD past the spec into the decision to build. And it reached *down to the metal* — a new `py` script type and a systematic port of the core scripts, git extension, and agent-context updater from shell to Python, alongside a security-hardening wave that bounded every HTTP read, validated every redirect hop, eliminated file-race conditions, and taught the workflow engine to fail loudly instead of crashing on malformed input. Meanwhile the ecosystem answered the project's most-cited critique — "who verifies the spec, and who reads all this documentation?" — not with complaints but with *code*: a wave of companion tools built directly on Spec Kit artifacts. None of this happens without the community — the contributors, extension and preset authors, bundle builders, agent-integration maintainers, and practitioners writing in more than 20 languages. Thank you.
|
||||
|
||||
## Spec Kit Project Updates
|
||||
|
||||
### Releases Overview
|
||||
|
||||
**v0.12.3–v0.12.18** (July 1–17) was the month's longest patch run and carried two features amid heavy hardening. The **`py` script type** landed (#3285), adding Python interpreter resolution alongside the existing `sh`/`ps` options, and the **label-driven bug-fix (#3258) and bug-test (#3239) agentic workflows** completed the `bug-assess → bug-test → bug-fix` triage pipeline. The systematic shell→Python port began here: the **`update-agent-context` script** (#3387), the **git extension scripts** (#3400), and a **`check-prerequisites` proof-of-concept** (#3302) were all ported. **PyPI was documented as a first-class second install route** (#3516). New agents arrived — **Grok Build** (#3535) as a skills-based integration — while **Roo Code was retired** as a shut-down product (#3212). The rest was a broad defensive-validation sweep across the workflow engine (case-insensitive gate reject, quote-aware interpolation, host-less catalog-URL rejection, and dozens of "fail loudly on malformed input" guards). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.13.0–v0.13.4** (July 17–22) delivered the month's headline feature: the **`assess` Idea Assessment Pipeline extension** (#3568), a pre-spec evaluation flow. The release also completed the Python port of the three core scripts — **`create-new-feature`, `setup-plan`, and `setup-tasks`** (#3386) — and added **Azure DevOps `az`-CLI token acquisition** hardening (#3527), **community bundle submission automation** (#3553), and the standalone **`WorkflowResolver`** refactor (#3557). **Factory Droid CLI** joined as an integration (#3587), **Bob was updated to a skills-based layout for Bob 2.0** (#3415), the **`pipeline` workflow** was added to the community catalog (#3338), and the **spec-of-specs feature-breakdown** approach was documented for handling complex features (#3648). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.14.0–v0.14.4** (July 23–29) crossed a minor with a **security-hardening focus**: **bounded HTTP reads and strict redirect enforcement** (#3140, #3671), **secured extension/preset archive downloads** (#3141), and the **removal of the `shell` parameter from `run_command`** (#3716). The **git extension gained configurable Conventional Commit support** (#3413), the wheel now **bundles `scripts/python`** so `--script py` works from a clean install (#3665), and **Alquimia AI** joined as the month's third new agent (#2734). Documentation added a **Simplified Chinese README translation** (#3740), and the constitution stopped **propagating guidance into templates** (#3790). A long run of bundler, preset, and integration validation fixes rounded out the cluster. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.15.0–v0.15.1** (July 30–31) closed the month with a **first-class agent-native runtime events layer for integrations** (#3704) — the release's headline — plus a continued security pass: **TOCTOU-race elimination in file-unlink calls** (#3811, #3815, #3819), **UTF-8 encoding on registry file opens** (#3810, #3816), and **hardening of the extension URL-download cache against symlink/junction races** (#3869). Workflows gained the ability to **bind a gate verdict to a workflow input via `verdict_input`** (#3725), an opt-in **`constitution-sync` preset** shipped (#3873), the **`yolo` workflow** was added to the community catalog (#3864), and installs gained **tar-archive support** (#3874). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Idea Assessment Pipeline: `assess`
|
||||
|
||||
July's headline feature was the **`assess` extension** (#3568), an *idea-assessment pipeline* that ships as an opt-in extension and extends spec-driven development one step further upstream than it has ever reached. Where the core lifecycle begins at `/speckit.specify` — assuming the decision to build has already been made — `assess` addresses the question that comes *before* the spec: **should this idea be built at all, and is it understood well enough to specify?**
|
||||
|
||||
The pipeline runs a staged flow — **capture → evidence → refine → design → decision** — that takes a raw idea, gathers supporting evidence, refines it into something concrete, sketches a design, and terminates in an explicit **go / clarify / kill** verdict. A `go` feeds a well-formed problem into the existing `/speckit.specify` step; a `clarify` routes back for more information; a `kill` stops work before a line of spec is written. Its input is just an idea — pasted text, a URL, a ticket, or a codebase pointer — so the pipeline works **equally well on an empty, freshly-initialized project or on an existing codebase** (#3732); a team can evaluate a green idea before any scaffolding exists, or assess a change against a repo that already has one.
|
||||
|
||||
The feature drew the month's most prominent mainstream-press coverage: **heise online** ran *"From Idea to Spec: The New Feature in Spec Kit 0.13"* in both English and German, framing `assess` as the notable addition of the 0.13 line alongside the Azure DevOps CLI support and the bundler/preset validation fixes. Coming from a major European technology outlet rather than a developer blog, it was a signal that Spec Kit's release cadence is now tracked as mainstream tooling news. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
|
||||
|
||||
### The Python Migration: the `py` Script Type
|
||||
|
||||
Spec Kit's second July theme was quieter but structurally important: the project began migrating its shell scripts to **Python**. The new **`py` script type** (#3285) joins `sh` (bash) and `ps` (PowerShell) as a third option at `specify init`, backed by Python-interpreter resolution that skips broken stubs (including the Windows Store `python3` alias, #3385). The `py` type is the project's answer to the perennial bash/PowerShell parity tax — every script fix previously had to be written twice and kept in sync, a recurring source of the Windows-parity bugs that filled prior months' changelogs.
|
||||
|
||||
Behind the new type, a systematic port landed piece by piece across the month: the **`update-agent-context`** updater (#3387), the **git extension scripts** (#3400), a **`check-prerequisites`** proof-of-concept (#3302), and finally the three core scripts — **`create-new-feature`, `setup-plan`, and `setup-tasks`** (#3386). The wheel was updated to bundle `scripts/python` so `--script py` works from a clean PyPI install (#3665), and the installation docs and init option table were updated to document the new type and the sh/ps migration plan (#3284, #3640). The end state is a single, cross-platform script implementation that removes an entire class of parity bugs. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### Agent-Native Runtime Events
|
||||
|
||||
The v0.15.0 headline was a **first-class agent-native runtime events layer for integrations** (#3704). It bridges Spec Kit to the host agent's own lifecycle via a set of canonical, snake_case event names — `session_start`, `pre_tool_use`, `post_tool_use`, `user_prompt_submit`, `stop`, and `session_end`. A lightweight, zero-dependency **Event Dispatcher** (`.specify/events.py`) is scaffolded during `specify init`, and per-integration **Event Adapters** translate each canonical event into the agent's *native* hook configuration — `.github/hooks/speckit.json` (bash/PowerShell variants) for **Copilot CLI**, `.claude/settings.json` for Claude Code, `.cursor/hooks.json` for Cursor, `.codex/config.toml` for Codex, a TypeScript plugin for opencode, and native settings merges for Gemini, Qwen, Devin, and Tabnine — so extension authors declare `events:` in `extension.yml` once and never learn agent-specific names. Resolution is a four-tier stack (CLI `--events false` → user `.specify/integration-events.yml` override → extension-declared events → built-in defaults), and multiple extensions declaring the same event all run. The change accompanied a broader integration-refinement run: agents that use an always-slash invocation (Droid, Forge, Cline) now render hyphenated `/speckit-<name>` commands correctly (#3688, #3642, #3622), native skill-invocation prefixes are preserved (#3663), and several agents (kiro-cli, Lingma, Pi, omp) were declared multi-install-safe. The through-line is that integrations are increasingly *native* to each agent rather than a lowest-common-denominator overlay. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Security-Hardening Wave
|
||||
|
||||
The dominant engineering theme across all twenty-eight releases was **security and robustness**. The work fell into three bands. **Bounded I/O:** every catalog, download, and bundle HTTP response is now read under a byte cap with strict redirect validation on every hop (#3140, #3671, #3763, #3141), closing a class of unbounded-read / DoS exposure. **Race elimination:** TOCTOU races in file-unlink and state-file handling were removed (#3811, #3815, #3819), the extension URL-download cache was hardened against symlink and junction races (#3869), and registry file opens were pinned to UTF-8 (#3810, #3816). **Injection and input hardening:** the `shell` parameter was removed from `run_command` (#3716), user-supplied catalog metadata is escaped in every discovery/list/`init` output path (#3772, #3773, #3774, #3806, #3826, #3863), and catalog URLs are re-validated *after* redirects to preserve HTTPS/host guarantees (#3523, #3524).
|
||||
|
||||
Running alongside this was a systematic **"fail loudly, don't crash"** campaign across the workflow engine and catalog loaders: dozens of PRs replaced raw `ValueError`/`OverflowError`/crash paths with clean validation errors on malformed input — non-string commands, prompts, integrations, and models; non-list branches and `wait_for` entries; `priority: .inf` and boolean priorities; non-mapping manifest blocks; and superscript-digit gate prompts. The entire month's hardening arrived as prevention rather than response. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Workflow Engine & Bundles Mature
|
||||
|
||||
Beyond hardening, the **workflow engine** kept gaining capability. Steps can now read their **workflow source directory** (#3469), the shell and prompt steps got **configurable, validated timeouts** (#3404, #3847, #3768), a **gate verdict can bind to a workflow input** via `verdict_input` (#3725), and the **`WorkflowResolver`** was extracted as a standalone component (#3557). Two community workflows reached the catalog — the guided **`pipeline`** (#3338, which chains into the core `/speckit.converge`) and **`yolo`** (#3864) — bringing the standalone-workflow count to two.
|
||||
|
||||
The **bundle subsystem** introduced in June matured through a long tail of correctness work — reproducible builds via canonical POSIX arcnames (#3658), literal UTF-8 manifest dumps (#3660), strict rejection of malformed `requires`/`provides`/`integration`/`catalogs` blocks, and a clean `BundlerError` on malformed download URLs (#3586). **Community bundle submission automation** landed (#3553) and the **SicarioSpec Security & Governance Bundle** became a cataloged community bundle (#3636), making bundles a live community-submittable artifact type in practice. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### Agent Integrations
|
||||
|
||||
The agent portfolio grew net **+3 to 37 integrations**. Three joined — **Grok Build** (#3535), **Factory Droid CLI** (#3587, closing the 300+-day #822), and **Alquimia AI** (#2734) — while **Roo Code** was retired as a shut-down product (#3212). **Bob** was migrated to a skills-based layout for Bob 2.0 (#3415), **Kilocode** now installs commands under `.kilo/commands` (#3672), and a broad correctness pass fixed hyphenated-command rendering and dispatch for the always-slash agents (Droid, Forge, Cline) and preserved native skill-invocation prefixes (#3663). The pattern continues from June — pruning dead products while making the surviving integrations more native to each agent. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Extension & Preset Ecosystem
|
||||
|
||||
The community extension catalog grew from 125 to **144 entries** during July — nineteen net additions. Community presets grew from 23 to **29**, community workflows reached **2**, and the first community **bundle** (SicarioSpec) was cataloged.
|
||||
|
||||
Notable new extensions by category:
|
||||
|
||||
- **Verification, drift & evidence**: Test Coverage Drift Control, PatchWarden Evidence Pack, Quality Gates (Enforcement Layer), Verify Review Ship, Intent Reconciliation
|
||||
- **Requirements & intake**: EARS Requirements Syntax, the `assess` Idea Assessment Pipeline, Spec-Kit BDD, Charter
|
||||
- **External trackers & round-trip**: Linear Weave, Multi-Repo Branch Sync, ContextForge MCP
|
||||
- **Design & docs**: Spec Kit Figma, Figma Starter, Blueprint Index — Living Architecture Map, LLM Wiki, Dotdog
|
||||
- **Knowledge & orchestration**: OKF Knowledge Bundle Generator, Orchestration Task Context Management, Spec Kit Memory
|
||||
|
||||
The catalog also showed strong maintenance activity: **DocGuard — CDD Enforcement** advanced through several releases (to v0.33.0), **Verify Review Ship** and **Quality Gates (Enforcement Layer)** iterated rapidly, and **Architecture Guard**, **Golden Demo**, **Coding Standards Drift Control**, **Ripple**, and the **Ralph Loop** all shipped updates. The preset side was the month's busiest: a large **governance-preset** family expanded and iterated — the **Autonomous Run Governance** and **Parallel Autonomous Run Governance** presets, a full **Intake** governance suite (Authoring, Review, Sequencing), **Test-First Governance**, and coordinated version bumps across the A11Y, Agent-Parity, Cross-Platform, iSAQB-Architecture, Architecture, and Security governance presets. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
|
||||
|
||||
### Documentation & Docs Site
|
||||
|
||||
July's documentation work paired the new features with a landing-page refresh. The **spec-of-specs feature-breakdown** approach was documented for handling complex features (#3648), the **`py` (Python) script type** was documented in the installation guide and init option table (#3284, #3625, #3640), and the **`__SPECKIT_COMMAND`** token for portable cross-command references was documented (#3503). The landing page was reframed to weave the **harness/SDLC framing** and modernize the install and positioning story (#3565, #3567), ecosystem stats were refreshed (#3561), and **`extensions.yml` hook configuration** was documented (#3563). Upgrade guidance clarified that project-file upgrades flow through `integration upgrade` / `extension update` (#3326) and that Claude Code files live in `.claude/skills` (#3708). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
## Community & Content
|
||||
|
||||
### Press and Industry Coverage
|
||||
|
||||
July's coverage shifted from "what is SDD" explainers toward tool comparisons, companion tooling, and pointed "who verifies the spec?" critiques. No first-party Microsoft or GitHub post appeared in July; the nearest remained June's Microsoft Developer Blog piece.
|
||||
|
||||
**heise online** (Wolf Hosbach, July 21) was the month's most prominent mainstream-press coverage, publishing *"From Idea to Spec: The New Feature in Spec Kit 0.13"* in both English and German — news coverage of the `assess` Idea Assessment Pipeline, the Azure DevOps CLI support, and the 0.13 validation fixes. Mainstream European tech press now tracks Spec Kit's minor releases as tooling news. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
|
||||
|
||||
**Towards AI** (Rost Glukhov, July 12) compared **GitHub Spec Kit vs Kiro vs Claude Code** on SDD workflow rather than model capability, part of a July-long current of "which SDD tool?" comparisons that increasingly pit Spec Kit specifically against Kiro. [\[pub.towardsai.net\]](https://pub.towardsai.net/github-spec-kit-vs-kiro-vs-claude-code-sdd-workflows-a9e7fab3e545)
|
||||
|
||||
**ranjankumar.in** (Ranjan Kumar, July 13) argued that four SDD frameworks — BMAD, Spec Kit, Kiro, and Superpowers — converge on the same structural "invariants," engaging Spec Kit's actual internals (`workflows.md`, run-state `state.json`) rather than treating it as a black box. [\[ranjankumar.in\]](https://ranjankumar.in/spec-driven-development-invariants-not-frameworks)
|
||||
|
||||
Release-trackers continued their factual coverage of the 0.13–0.15 run, and **Level Up Coding** (JingJing "Chris" Bao) published a three-part practitioner series on Spec Kit's presets, extensions, and pipeline/workflow features as the path beyond linear slash-commands. [\[levelup.gitconnected.com\]](https://levelup.gitconnected.com/from-linear-commands-to-automated-pipelines-how-spec-kit-orchestrates-nonlinear-ai-development-55ca03c5617b)
|
||||
|
||||
### The Companion-Tooling Ecosystem
|
||||
|
||||
July's most telling signal was not an article but a pattern: independent developers responded to Spec Kit's most-cited critiques by **building tools on top of it**. The recurring complaint — documentation proliferation and "who verifies the generated spec?" — turned into code.
|
||||
|
||||
- **artgraph** (mori-shin, July 20) — a deterministic, hash-based spec↔code drift-detection CLI with an `artgraph integrate speckit` hook, built specifically to give Spec Kit's LLM-prompt-based verification a deterministic backstop. [\[zenn.dev\]](https://zenn.dev/mrmtsntr/articles/artgraph-spec-code-drift)
|
||||
- **SpecJudge** (Joaquín Ruiz, July 21) — a companion CLI that reads Spec Kit's constitution/spec/tasks artifacts to recommend a *right-sized* model for the project. [\[dev.to\]](https://dev.to/jokiruiz/specjudge-which-ai-model-is-right-sized-for-your-project-ask-your-specs-2edp)
|
||||
- **GAUNTLEX** (Sanjoy Ghosh, July 16) — named Spec Kit a leading SDD tool while arguing SDD leaves a security-testing gap, and shipped a tool to fill it. [\[hashnode.dev\]](https://sanjoy1234.hashnode.dev/the-testing-gap-nobody-s-talking-about-in-spec-driven-development)
|
||||
- **Custom skills** — [`speckit-next`](https://qiita.com/htcd/items/ec76f2b7194be3297b93) (htcd, July 31), a skill that recommends the next command because the names and order are hard to remember, and [`prefill`](https://velog.io/@k3nta/ai-adoption-journey-1-tools) (k3nta, July 1), a skill that patches `clarify`'s blind spots.
|
||||
|
||||
Together these are the clearest evidence yet that Spec Kit has become a *platform* — its artifacts are stable enough, and its gaps well-enough understood, that a third-party tooling layer is forming around it. [\[zenn.dev\]](https://zenn.dev/mrmtsntr/articles/artgraph-spec-code-drift)
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
July's articles skewed heavily multilingual — strong hands-on series in Japanese, Chinese, and Korean — with a clear thread of honest, use-it-in-anger critique.
|
||||
|
||||
Notable articles:
|
||||
|
||||
- **ta_kawano** (note.com, July 28–31) published a consolidated four-part **Kiro vs Spec Kit** head-to-head, completing a 108-task / 301-test build with Spec Kit where Kiro ran out of credit, praising measurable Success Criteria and auto-listed edge cases while flagging ~15,000 lines of generated documentation — and reframing Spec Kit as a requirements-elicitation tool. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
|
||||
- **magebyte / 码哥字节** (SegmentFault, July 26) built a Go REST API through the five-step workflow, covered the three spec-persistence models and the extension/preset system, and claimed ~80% less AI "hallucination" rework. [\[segmentfault.com\]](https://segmentfault.com/a/1190000048082146)
|
||||
- **Nil Seri** (Medium, July 16) published a brownfield guide adding Spec Kit to an existing Spring Boot / Maven project with Jira and Confluence integration — "from Jira ticket to verified code." [\[medium.com\]](https://medium.com/@senoritadeveloper/using-spec-kit-in-an-existing-spring-boot-maven-project-from-jira-ticket-to-verified-code-4e6da99b5d19)
|
||||
- **kitroc7134** (Qiita, July 4) tested `/speckit.converge` with deliberate fault-injection on a FastAPI Todo API — detect drift → append convergence tasks → re-implement — validating June's convergence loop in the field. [\[qiita.com\]](https://qiita.com/kitroc7134/items/117d4839f259bc403626)
|
||||
- **yutakaosada** (Zenn, July 25) — a Microsoft-MVP .NETラボ talk that uses Spec Kit but candidly flags AI-credit consumption, over-production of docs, and single-source-of-truth collapse, comparing it with Copilot Plan mode. [\[zenn.dev\]](https://zenn.dev/yutakaosada/articles/70e01981647159)
|
||||
|
||||
Additional coverage appeared on TechWealthBuzz, Hashnode, TabNews-adjacent outlets, CSDN and 腾讯云 (Chinese), Naver/velog/Tistory (Korean), and Qiita/note (Japanese) — including several "is it too heavy?" and documentation-proliferation critiques, and a [Korean instructor's piece](https://blog.naver.com/gaussian88/224363268926) citing Spec Kit's star growth from ~90k in May to ~120k in July. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
|
||||
|
||||
### Community Growth by the Numbers
|
||||
|
||||
| Metric | Start of July | End of July | Change |
|
||||
| --- | --- | --- | --- |
|
||||
| GitHub stars | 117,423 | 124,655 | +7,232 (+6%) |
|
||||
| Forks | 10,382 | 11,125 | +743 |
|
||||
| Contributors | 245 | ~258 | +~13 |
|
||||
| Releases (total) | 177 | 205 | +28 (v0.12.3–v0.15.1) |
|
||||
| Community extensions | 125 | 144 | +19 |
|
||||
| Community presets | 23 | 29 | +6 |
|
||||
| Community workflows | 1 | 2 | +1 |
|
||||
| Community bundles | 1 | 1 | steady |
|
||||
| Agent integrations | 34 | 37 | +3 (net) |
|
||||
| Discussions (open) | 457 | ~467 | +~10 |
|
||||
|
||||
## SDD Ecosystem & Industry Trends
|
||||
|
||||
### From Tool to Platform
|
||||
|
||||
July's clearest ecosystem signal was structural: the conversation moved from "how do I use Spec Kit?" to "what do I build *around* it?" The companion tools — artgraph for deterministic drift, SpecJudge for model sizing, GAUNTLEX for the testing gap, and a growing set of custom agent skills — treat Spec Kit's artifacts (constitution, spec, tasks, run-state) as a stable substrate to build against. The public community catalog reinforces the point: the loudest theme across the 144 cataloged extensions is verification and quality (review, validate, drift, sync, verify, audit), and core SDD verbs are increasingly *re-expressed* by extensions rather than merely overridden — evidence of demand for composable, overridable core commands. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
|
||||
|
||||
### Competitive Landscape
|
||||
|
||||
The "which SDD tool?" comparison remained the dominant content genre, but July's framing narrowed: where June's surveys ran a seven-tool field, July's most substantive pieces increasingly went head-to-head **Spec Kit vs Kiro** (ta_kawano, [faruryo](https://qiita.com/faruryo/items/87a14728299e89f80ff4), Towards AI). The recurring verdict held — Spec Kit is the heaviest and most flexible option, strong on measurable Success Criteria, requirements elicitation, and greenfield decomposition, while its documentation proliferation and cognitive load are the consistent trade-off. The convergence-invariants analyses (ranjankumar.in) went further, arguing the frameworks are converging on the same structural primitives, which shifts the competitive question from "which tool" to "which ecosystem and governance model." On that axis, Spec Kit's widening catalog, agent-neutrality, and now a forming companion-tooling layer are its differentiators. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
|
||||
|
||||
## Roadmap
|
||||
|
||||
Areas under discussion or in progress for future development:
|
||||
|
||||
- **Upstream of the spec** — the `assess` Idea Assessment Pipeline extends SDD before the spec exists. Expect the capture→evidence→refine→design→decision flow to deepen, and the boundary between idea assessment and `/speckit.specify` to be a key area to refine as the pipeline sees real use. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
|
||||
- **The Python migration** — the `py` script type and the port of the core scripts, git extension, and agent-context updater establish Python as the path out of the bash/PowerShell parity tax. Completing the port and making `py` a well-trodden default (rather than sh/ps) is the payoff: an entire class of Windows-parity bugs disappears. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Agent-native runtime events** — the first-class events layer lets integrations wire Spec Kit into each agent's own runtime through canonical event names and per-agent adapters. The layer is **actively evolving** — early signals point to opencode context injection and JSON-envelope agent hooks. Expect more agents to gain event adapters and extension authors to lean on the declarative `events:` surface as the integration layer shifts from lowest-common-denominator overlay to genuinely native behavior. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Copilot skills as the default** — July shipped a warning ahead of the skills-default rollout, and the signals now point to the **default flip being in progress** — moving `specify init --integration copilot` to the skills-based layout and making the default init integration overridable via an environment variable, with the markdown-command layout becoming the legacy path. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **A Copilot-native surface** — the first-party [`github/spec-kit-copilot`](https://github.com/github/spec-kit-copilot) repo (a listed community friend) wraps the `specify` CLI as a **Copilot skills plugin** (nine skills across setup, init, extensions, presets, bundles, workflows, and self-upgrade) for the Copilot CLI and App, aligned to CLI v0.15.0. The emerging direction is a **visual, Copilot-driven surface** — early work explores canvas dashboards for the Spec-Driven Development flow, a Bug Fix Pipeline, and `assess` — turning the CLI's flows into an interactive layer. [\[github.com\]](https://github.com/github/spec-kit-copilot)
|
||||
- **The companion-tooling layer** — artgraph, SpecJudge, GAUNTLEX, and custom skills signal a third-party ecosystem forming on Spec Kit artifacts. The open question is whether the project absorbs these patterns (as it did drift → `converge`) or leaves them to the ecosystem; the verification/drift demand in the extension catalog suggests continued upstream pull. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Security and robustness as a standing discipline** — July's hardening wave (bounded reads, strict redirects, TOCTOU elimination, fail-loudly validation) shifted from feature to routine, and the signals point to it **continuing as an ongoing campaign** — a stdin read cap to close a DoS path and a broader atomicity push (atomic temp-file writes, subprocess timeouts, structured logging, and narrowed exception handlers). Sustaining the no-unbounded-read invariant as the surface (bundles, workflows, catalogs, events) grows is the ongoing work. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Experience simplification** — documentation proliferation and cognitive load remain the single most-cited concern across July's balanced reviews (ta_kawano, yutakaosada, and multiple Japanese/Korean pieces). The `assess` upstream gate, the lean/TinySpec presets, `/speckit.converge`, and the forming companion-tooling layer all provide answers; surfacing them to new users is the persistent opportunity. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
|
||||
@@ -1,14 +1,14 @@
|
||||
# Spec Kit - June 2026 Newsletter
|
||||
|
||||
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June brought broad validation — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and coverage spanned dozens of languages. A summary is in the table below, followed by details.
|
||||
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June was the highest-volume press month on record — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and 75 substantive articles appeared across 25+ languages. A summary is in the table below, followed by details.
|
||||
|
||||
| **Spec Kit Core (Jun 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. Press coverage spanned dozens of languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
|
||||
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. June was the highest-volume press month yet — **75 substantive articles** across 25+ languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
|
||||
|
||||
***
|
||||
|
||||
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. Coverage appeared in dozens of languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
|
||||
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. A record **75 substantive articles** appeared in more than 25 languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
|
||||
|
||||
## Spec Kit Project Updates
|
||||
|
||||
@@ -84,7 +84,7 @@ On **June 10**, the **Microsoft Developer Blog** published *"Spec-Driven Develop
|
||||
|
||||
### Press and Industry Coverage
|
||||
|
||||
June's press coverage spanned dozens of languages and platforms.
|
||||
June was the **highest-volume coverage month on record — 75 substantive articles** across more than 25 languages.
|
||||
|
||||
**Xebia / XPRT Magazine #21** (Hidde de Smet & Emanuele Bartolesi, June 17) published a 32-minute full six-command walkthrough covering both greenfield and brownfield, honest about markdown-review overhead and where spec quality becomes the bottleneck. [\[xebia.com\]](https://xebia.com/blog/building-software-with-spec-kit/)
|
||||
|
||||
@@ -102,7 +102,7 @@ June's press coverage spanned dozens of languages and platforms.
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
June's coverage skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
|
||||
June's 75 articles skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
|
||||
|
||||
Notable English-language articles:
|
||||
|
||||
@@ -137,7 +137,7 @@ Coverage also appeared on TabNews (Portuguese), Habr and CSDN, note.com, Substac
|
||||
|
||||
### The Category Consolidates
|
||||
|
||||
Across June's broad article coverage, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
|
||||
Across June's record article volume, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
|
||||
|
||||
### Competitive Landscape
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This edition covers Spec Kit activity in May 2026 — a month defined by three m
|
||||
|
||||
| **Spec Kit Core (May 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Fourteen releases shipped with key features: multi-install for concurrent agent integrations, constitution governance in implement, authentication provider registry, Hermes and Lingma agents, and a `__init__.py` decomposition series. The repo grew from ~92k to **106,951 stars**, crossing **100K** on May 21. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog crossed **100 entries** (now 105). Open Source Friday livestream drove a press wave: Visual Studio Magazine, DevOps.com, MarkTechPost, HackerNoon, and many more across multiple languages. **217 contributors** now listed. | MarkTechPost called Spec Kit "the most community-adopted open-source option" for SDD. The Futurum Group's Mitch Ashley framed specs as "the unit of governance across agents and contributors." Truong Phung published a 61-min production playbook referencing Spec Kit. Competitors grew but differentiate on orchestration; Spec Kit leads in portability and community. |
|
||||
| Fourteen releases shipped with key features: multi-install for concurrent agent integrations, constitution governance in implement, authentication provider registry, Hermes and Lingma agents, and a `__init__.py` decomposition series. The repo grew from ~92k to **106,951 stars**, crossing **100K** on May 21. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog crossed **100 entries** (now 105). Open Source Friday livestream drove a press wave: Visual Studio Magazine, DevOps.com, MarkTechPost, HackerNoon, and 25+ more articles — now tracked across multiple languages following an expanded discovery methodology. **217 contributors** now listed. | MarkTechPost called Spec Kit "the most community-adopted open-source option" for SDD. The Futurum Group's Mitch Ashley framed specs as "the unit of governance across agents and contributors." Truong Phung published a 61-min production playbook referencing Spec Kit. Competitors grew but differentiate on orchestration; Spec Kit leads in portability and community. |
|
||||
|
||||
***
|
||||
|
||||
@@ -76,7 +76,7 @@ May produced the broadest press coverage to date, with publications from the mai
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
May produced a wave of independent coverage across many languages.
|
||||
May produced a wave of independent coverage — well beyond any previous month. Starting this month, article discovery was expanded beyond English-centric search engines to include language-appropriate engines for 25+ languages, so the broader coverage partly reflects wider discovery rather than a sudden spike.
|
||||
|
||||
Notable non-English coverage:
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.16.1"
|
||||
version = "0.15.2"
|
||||
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"
|
||||
|
||||
@@ -130,31 +130,14 @@ def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
|
||||
print(f"TASKS: {paths.tasks}")
|
||||
|
||||
|
||||
def _status_marker(ok: bool) -> str:
|
||||
"""Return the status glyph, downgraded to ASCII when stdout cannot encode it.
|
||||
|
||||
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
|
||||
console - a pipe or a file redirect, which is how agents and workflow steps
|
||||
invoke these scripts - and U+2713 is unencodable in cp1252, so printing it
|
||||
raised UnicodeEncodeError and aborted the report right after
|
||||
"AVAILABLE_DOCS:". "[OK]"/"[FAIL]" is the ASCII rendering these markers
|
||||
already have in-tree: see Test-FileExists in scripts/powershell/common.ps1
|
||||
and normalize_status_text in tests/parity_helpers.py.
|
||||
"""
|
||||
glyph = "✓" if ok else "✗"
|
||||
try:
|
||||
glyph.encode(getattr(sys.stdout, "encoding", None) or "utf-8")
|
||||
except (LookupError, UnicodeEncodeError):
|
||||
return "[OK]" if ok else "[FAIL]"
|
||||
return glyph
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
print(f" {_status_marker(path.is_file())} {description}")
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
print(f" {_status_marker(_dir_has_entries(path))} {description}")
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
|
||||
|
||||
@@ -55,31 +55,14 @@ def _available_docs(paths: FeaturePaths) -> list[str]:
|
||||
return docs
|
||||
|
||||
|
||||
def _status_marker(ok: bool) -> str:
|
||||
"""Return the status glyph, downgraded to ASCII when stdout cannot encode it.
|
||||
|
||||
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
|
||||
console - a pipe or a file redirect, which is how agents and workflow steps
|
||||
invoke these scripts - and U+2713 is unencodable in cp1252, so printing it
|
||||
raised UnicodeEncodeError and aborted the report mid-listing.
|
||||
"[OK]"/"[FAIL]" is the ASCII rendering these markers already have in-tree:
|
||||
see Test-FileExists in scripts/powershell/common.ps1 and
|
||||
normalize_status_text in tests/parity_helpers.py.
|
||||
"""
|
||||
glyph = "✓" if ok else "✗"
|
||||
try:
|
||||
glyph.encode(getattr(sys.stdout, "encoding", None) or "utf-8")
|
||||
except (LookupError, UnicodeEncodeError):
|
||||
return "[OK]" if ok else "[FAIL]"
|
||||
return glyph
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
print(f" {_status_marker(path.is_file())} {description}")
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
print(f" {_status_marker(_dir_has_entries(path))} {description}")
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
@@ -10,7 +10,6 @@ import struct
|
||||
import tarfile
|
||||
import unicodedata
|
||||
import zipfile
|
||||
import zlib
|
||||
from collections.abc import Iterator
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||
@@ -70,19 +69,6 @@ _ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
|
||||
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
|
||||
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
|
||||
)
|
||||
#: Decompression failures a truncated or corrupt gzip stream raises from
|
||||
#: ``tarfile``. Most are wrapped in ``TarError``, but two escape raw, and
|
||||
#: neither derives from ``TarError`` or ``OSError``, so both bypass a
|
||||
#: ``(TarError, OSError)`` handler:
|
||||
#:
|
||||
#: * ``EOFError`` -- from the gzip layer when the stream ends before its
|
||||
#: end-of-stream marker, i.e. a truncated archive.
|
||||
#: * ``zlib.error`` -- from a corrupt deflate block. ``tarfile`` converts this
|
||||
#: to ``ReadError`` while reading a member *header*, but the forward seek it
|
||||
#: performs to skip member *data* sits outside that conversion, so a corrupt
|
||||
#: region past the first header escapes raw.
|
||||
_TAR_DECOMPRESSION_ERRORS = (tarfile.TarError, EOFError, zlib.error)
|
||||
|
||||
_ARCHIVE_CONTENT_TYPES: dict[str, ArchiveFormat] = {
|
||||
"application/gzip": "tar.gz",
|
||||
"application/x-gzip": "tar.gz",
|
||||
@@ -180,11 +166,7 @@ def detect_archive_format(
|
||||
try:
|
||||
with tarfile.open(fileobj=archive_file, mode="r:gz"):
|
||||
is_tar_gz = True
|
||||
except _TAR_DECOMPRESSION_ERRORS:
|
||||
# A truncated gzip stream raises a bare EOFError here rather
|
||||
# than a TarError, so catching only TarError let it escape
|
||||
# this probe as a raw exception instead of leaving
|
||||
# ``is_tar_gz`` False and reporting the format mismatch.
|
||||
except tarfile.TarError:
|
||||
pass
|
||||
archive_file.seek(0)
|
||||
except OSError as exc:
|
||||
@@ -1095,7 +1077,7 @@ def safe_extract_tar(
|
||||
mode="r:gz",
|
||||
fileobj=archive_file,
|
||||
)
|
||||
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
_raise_from(error_type, f"Invalid tar.gz archive: {archive_path}", exc)
|
||||
|
||||
with archive:
|
||||
@@ -1167,7 +1149,7 @@ def safe_extract_tar(
|
||||
f"of {max_total_bytes} bytes",
|
||||
)
|
||||
validated.append((member, normalized_name, is_dir))
|
||||
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
_raise_from(
|
||||
error_type,
|
||||
f"Invalid tar.gz archive: {archive_path}",
|
||||
|
||||
@@ -192,8 +192,8 @@ def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker
|
||||
|
||||
os.replace(temp_path, target_file)
|
||||
except Exception:
|
||||
if temp_path:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
if temp_path and temp_path.exists():
|
||||
temp_path.unlink()
|
||||
raise
|
||||
|
||||
try:
|
||||
|
||||
@@ -157,11 +157,7 @@ class CommandRegistrar:
|
||||
return ""
|
||||
|
||||
yaml_str = yaml.dump(
|
||||
fm,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
width=float("inf"),
|
||||
fm, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||
)
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
|
||||
@@ -771,17 +771,7 @@ def _local_manifest_source(arg: str):
|
||||
error_type=BundlerError,
|
||||
label="bundle manifest",
|
||||
)
|
||||
try:
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
except _yaml.YAMLError as exc:
|
||||
# The sibling directory/bundle.yml branches reach YAML through
|
||||
# load_yaml(), which turns a parse failure into a BundlerError. This
|
||||
# branch parses inline, so without this it raises a raw YAMLError --
|
||||
# neither a ValueError nor an OSError -- which escapes
|
||||
# bundle_install()'s `except BundlerError` as a traceback.
|
||||
raise BundlerError(
|
||||
f"Invalid YAML in bundle.yml inside '{candidate}': {exc}"
|
||||
) from exc
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"):
|
||||
|
||||
@@ -24,19 +24,8 @@ def event_run(
|
||||
"""Resolve and run an event-driven command script with stdin payload."""
|
||||
from ..events import resolve_and_run_event_command
|
||||
|
||||
# Read payload from stdin if available (capped at 1 MiB to prevent DoS).
|
||||
MAX_STDIN_BYTES = 1 * 1024 * 1024
|
||||
if not sys.stdin.isatty():
|
||||
raw = sys.stdin.read(MAX_STDIN_BYTES)
|
||||
if not sys.stdin.eof:
|
||||
raise typer.Exit(
|
||||
code=1,
|
||||
message="stdin payload exceeds 1 MiB limit; "
|
||||
"truncate or pipe a smaller payload",
|
||||
)
|
||||
payload = raw
|
||||
else:
|
||||
payload = "{}"
|
||||
# Read payload from stdin if available
|
||||
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
|
||||
|
||||
# Run the event command
|
||||
project_root = Path.cwd() # The agent runs events from project root
|
||||
|
||||
@@ -3,16 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.live import Live
|
||||
from rich.markup import escape as _escape_markup
|
||||
from rich.panel import Panel
|
||||
|
||||
from .._agent_config import (
|
||||
@@ -172,25 +169,6 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
|
||||
def _shell_quote_arg(value: str) -> str:
|
||||
"""Quote *value* as one argument for the shells of the host OS.
|
||||
|
||||
The Next Steps ``cd`` line is copy-pasted into whichever shell ran
|
||||
``specify init``, so it is quoted for the host the same way
|
||||
``_version._render_argv`` renders its copy-pasteable installer command:
|
||||
``list2cmdline`` on Windows, ``shlex.quote`` elsewhere. The Windows branch
|
||||
must emit double quotes -- ``cd 'my project'`` is a path-not-found in
|
||||
cmd.exe, while ``cd "my project"`` is accepted by cmd.exe, PowerShell and
|
||||
Git Bash alike. A value needing no quoting is returned unchanged.
|
||||
|
||||
Whitespace only. PowerShell also glob-expands ``[``/``]`` and expands
|
||||
``$``/backtick inside double quotes, so such a name still needs
|
||||
``Set-Location -LiteralPath`` there -- syntax invalid in cmd.exe and sh, so
|
||||
this shell-neutral line cannot cover it.
|
||||
"""
|
||||
return subprocess.list2cmdline([value]) if os.name == "nt" else shlex.quote(value)
|
||||
|
||||
|
||||
def ensure_constitution_from_template(
|
||||
project_path: Path, tracker: StepTracker | None = None
|
||||
) -> None:
|
||||
@@ -373,10 +351,7 @@ def register(app: typer.Typer) -> None:
|
||||
if integration:
|
||||
resolved_integration = get_integration(integration)
|
||||
if not resolved_integration:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Unknown integration: "
|
||||
f"'{_escape_markup(str(integration))}'"
|
||||
)
|
||||
console.print(f"[red]Error:[/red] Unknown integration: '{integration}'")
|
||||
available = ", ".join(sorted(INTEGRATION_REGISTRY))
|
||||
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
||||
raise typer.Exit(1)
|
||||
@@ -453,27 +428,26 @@ def register(app: typer.Typer) -> None:
|
||||
project_path = Path(project_name).resolve()
|
||||
dir_existed_before = project_path.exists()
|
||||
if project_path.exists():
|
||||
safe_name = _escape_markup(str(project_name))
|
||||
if not project_path.is_dir():
|
||||
console.print(
|
||||
f"[red]Error:[/red] '{safe_name}' exists but is not a directory."
|
||||
f"[red]Error:[/red] '{project_name}' exists but is not a directory."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
existing_items = list(project_path.iterdir())
|
||||
if force:
|
||||
if existing_items:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Directory '{safe_name}' is not empty ({len(existing_items)} items)"
|
||||
f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)"
|
||||
)
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
console.print(
|
||||
f"[cyan]--force supplied: merging into existing directory '[cyan]{safe_name}[/cyan]'[/cyan]"
|
||||
f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]"
|
||||
)
|
||||
else:
|
||||
error_panel = Panel(
|
||||
f"Directory already exists: '[cyan]{safe_name}[/cyan]'\n"
|
||||
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
|
||||
"Please choose a different project name or remove the existing directory.\n"
|
||||
"Use [bold]--force[/bold] to merge into the existing directory.",
|
||||
title="[red]Directory Conflict[/red]",
|
||||
@@ -487,7 +461,7 @@ def register(app: typer.Typer) -> None:
|
||||
if integration:
|
||||
if integration not in AGENT_CONFIG:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid integration '{_escape_markup(str(integration))}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
|
||||
f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
selected_ai = integration
|
||||
@@ -526,14 +500,12 @@ def register(app: typer.Typer) -> None:
|
||||
setup_lines = [
|
||||
"[cyan]Specify Project Setup[/cyan]",
|
||||
"",
|
||||
f"{'Project':<15} [green]{_escape_markup(project_path.name)}[/green]",
|
||||
f"{'Working Path':<15} [dim]{_escape_markup(str(current_dir))}[/dim]",
|
||||
f"{'Project':<15} [green]{project_path.name}[/green]",
|
||||
f"{'Working Path':<15} [dim]{current_dir}[/dim]",
|
||||
]
|
||||
|
||||
if not here:
|
||||
setup_lines.append(
|
||||
f"{'Target Path':<15} [dim]{_escape_markup(str(project_path))}[/dim]"
|
||||
)
|
||||
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
|
||||
|
||||
console.print(
|
||||
Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))
|
||||
@@ -560,7 +532,7 @@ def register(app: typer.Typer) -> None:
|
||||
if script_type:
|
||||
if script_type not in SCRIPT_TYPE_CHOICES:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid script type '{_escape_markup(str(script_type))}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
|
||||
f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
selected_script = script_type
|
||||
@@ -599,6 +571,8 @@ def register(app: typer.Typer) -> None:
|
||||
tracker.add(key, label)
|
||||
|
||||
if extensions:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
for i, ext_spec in enumerate(extensions):
|
||||
tracker.add(
|
||||
f"extension-{i}", f"Install extension: {_escape_markup(ext_spec)}"
|
||||
@@ -661,30 +635,6 @@ def register(app: typer.Typer) -> None:
|
||||
)
|
||||
manifest.save()
|
||||
|
||||
if force:
|
||||
from ..integrations._helpers import (
|
||||
_register_extensions_for_agent,
|
||||
_register_presets_for_agent,
|
||||
)
|
||||
|
||||
_register_extensions_for_agent(
|
||||
project_path,
|
||||
resolved_integration.key,
|
||||
force=True,
|
||||
continuing=(
|
||||
"The project was re-initialized, but installed extensions"
|
||||
" may need re-registration."
|
||||
),
|
||||
)
|
||||
_register_presets_for_agent(
|
||||
project_path,
|
||||
resolved_integration.key,
|
||||
continuing=(
|
||||
"The project was re-initialized, but installed presets"
|
||||
" may need re-registration."
|
||||
),
|
||||
)
|
||||
|
||||
integration_settings = _with_integration_setting(
|
||||
{},
|
||||
resolved_integration.key,
|
||||
@@ -853,6 +803,8 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
# Install extensions specified via --extension
|
||||
if extensions:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from ..extensions._commands import _refresh_events_and_warn
|
||||
|
||||
speckit_ver = get_speckit_version()
|
||||
@@ -942,7 +894,7 @@ def register(app: typer.Typer) -> None:
|
||||
if agent_folder:
|
||||
security_notice = Panel(
|
||||
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
||||
f"Consider adding [cyan]{_escape_markup(str(agent_folder))}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
||||
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
||||
title="[yellow]Agent Folder Security[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
@@ -953,7 +905,7 @@ def register(app: typer.Typer) -> None:
|
||||
steps_lines = []
|
||||
if not here:
|
||||
steps_lines.append(
|
||||
f"1. Go to the project folder: [cyan]cd {_escape_markup(_shell_quote_arg(str(project_name)))}[/cyan]"
|
||||
f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]"
|
||||
)
|
||||
step_num = 2
|
||||
else:
|
||||
|
||||
+33
-288
@@ -251,7 +251,7 @@ def _resolve_argv(template_path, project_root, ext_id):
|
||||
return [str(script_abs), *rest]
|
||||
|
||||
|
||||
def _run_inline(command_name, payload, project_root, timeout, envelope="plain", native_event=""):
|
||||
def _run_inline(command_name, payload, project_root, timeout):
|
||||
"""Resolve and run the event command with stdlib only (no specify_cli)."""
|
||||
template_path, ext_id = _find_command_template(command_name, project_root)
|
||||
if not template_path:
|
||||
@@ -269,7 +269,7 @@ def _run_inline(command_name, payload, project_root, timeout, envelope="plain",
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.stdout:
|
||||
_emit(result.stdout, envelope, native_event)
|
||||
sys.stdout.write(result.stdout)
|
||||
if result.returncode != 0:
|
||||
if result.stderr:
|
||||
sys.stderr.write(result.stderr)
|
||||
@@ -283,46 +283,6 @@ def _run_inline(command_name, payload, project_root, timeout, envelope="plain",
|
||||
return 2
|
||||
|
||||
|
||||
def _emit(output, envelope, native_event=""):
|
||||
"""Write handler output to stdout in the agent's context-injection shape.
|
||||
|
||||
Not every agent injects a hook's plain-text stdout as model context:
|
||||
Gemini/Tabnine/Qwen/Devin are JSON-only protocols (plain text becomes
|
||||
user-facing noise, never context), Copilot discards non-JSON stdout, and
|
||||
Cursor parses stdout as JSON. The native hook command passes the envelope
|
||||
as the dispatcher's 5th argument (see events_context_envelope on the
|
||||
integration classes), and the native event name as the 6th argument so
|
||||
hookSpecificOutput can include hookEventName:
|
||||
|
||||
hookSpecificOutput → {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
|
||||
additionalContext → {"additionalContext": ...} (top-level, Copilot)
|
||||
additional_context → {"additional_context": ...} (top-level, Cursor)
|
||||
suppress → emit nothing (strict-JSON agents on events whose
|
||||
output can't be used)
|
||||
plain (default) → passthrough (Claude/Codex inject plain stdout)
|
||||
|
||||
Empty output emits nothing under any envelope (an empty additionalContext
|
||||
is useless noise).
|
||||
"""
|
||||
if not output:
|
||||
return
|
||||
if envelope == "suppress":
|
||||
return
|
||||
if envelope == "hookSpecificOutput":
|
||||
payload = {"additionalContext": output}
|
||||
if native_event:
|
||||
payload["hookEventName"] = native_event
|
||||
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\\n")
|
||||
return
|
||||
if envelope == "additionalContext":
|
||||
sys.stdout.write(json.dumps({"additionalContext": output}) + "\\n")
|
||||
return
|
||||
if envelope == "additional_context":
|
||||
sys.stdout.write(json.dumps({"additional_context": output}) + "\\n")
|
||||
return
|
||||
sys.stdout.write(output)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(0)
|
||||
@@ -337,16 +297,6 @@ def main():
|
||||
timeout = int(sys.argv[3])
|
||||
except (TypeError, ValueError):
|
||||
timeout = 120
|
||||
# Optional 5th arg: context-injection envelope for stdout (C13): plain
|
||||
# (default), hookSpecificOutput, additionalContext, additional_context,
|
||||
# or suppress. Unknown values fall back to plain passthrough.
|
||||
envelope = sys.argv[4] if len(sys.argv) >= 5 else "plain"
|
||||
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "suppress"):
|
||||
envelope = "plain"
|
||||
# Optional 6th arg: native event name for hookSpecificOutput's
|
||||
# hookEventName field (required by Qwen's hooks spec; included by
|
||||
# Gemini/Tabnine/Devin which derive from the same protocol).
|
||||
native_event = sys.argv[5] if len(sys.argv) >= 6 else ""
|
||||
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
|
||||
@@ -357,14 +307,14 @@ def main():
|
||||
from specify_cli.events import resolve_and_run_event_command
|
||||
sys.exit(
|
||||
resolve_and_run_event_command(
|
||||
command_name, _event_name, payload, project_root, timeout=timeout, envelope=envelope, native_event=native_event
|
||||
command_name, _event_name, payload, project_root, timeout=timeout
|
||||
)
|
||||
)
|
||||
except (ImportError, TypeError):
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: self-contained stdlib resolver (one-time/temporary installs).
|
||||
sys.exit(_run_inline(command_name, payload, project_root, timeout, envelope, native_event))
|
||||
sys.exit(_run_inline(command_name, payload, project_root, timeout))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -412,21 +362,17 @@ function resolveDispatcher(directory: string): void {{
|
||||
) as string;
|
||||
}}
|
||||
|
||||
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): string {{
|
||||
if (!DISPATCHER) return '';
|
||||
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): void {{
|
||||
if (!DISPATCHER) return;
|
||||
try {{
|
||||
// execFileSync with an argv array invokes the interpreter directly — no
|
||||
// shell — so command/event strings with metacharacters can't break out
|
||||
// of the dispatcher argument (C9). The dispatcher arg is seconds; the
|
||||
// execFileSync timeout is ms with a buffer so the outer cap fires after
|
||||
// the dispatcher's inner subprocess (S3). stdout is captured and
|
||||
// returned so context-injection hooks (experimental.chat.system.transform,
|
||||
// chat.message) can push it into their outputs; stderr stays inherited so
|
||||
// dispatcher errors remain visible (C11).
|
||||
return execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
|
||||
// the dispatcher's inner subprocess (S3).
|
||||
execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
|
||||
input: JSON.stringify({{ input, output }}),
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
timeout: (timeoutSec + {buffer}) * 1000,
|
||||
}});
|
||||
}} catch (e) {{
|
||||
@@ -436,12 +382,6 @@ function runEvent(command: string, event: string, input: any, output: any, timeo
|
||||
}}
|
||||
}}
|
||||
|
||||
// Cache session_start handler output per sessionID so non-idempotent
|
||||
// handlers (setup, telemetry, file-mutating scripts) run once per session
|
||||
// instead of on every LLM request (experimental.chat.system.transform
|
||||
// fires per LLM turn). Evicted on session.deleted.
|
||||
const sessionStartCache = new Map<string, string>();
|
||||
|
||||
{event_entries}
|
||||
|
||||
export default (async ({{ client, project, directory, $ }}) => {{
|
||||
@@ -548,15 +488,7 @@ def _resolve_event_command_argv(
|
||||
"""
|
||||
from .integrations.base import IntegrationBase
|
||||
|
||||
try:
|
||||
content = template_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# An unreadable or undecodable template cannot declare a runnable
|
||||
# script. Degrade to "no argv" like every other failure in this
|
||||
# resolver (missing frontmatter, malformed YAML, absent scripts)
|
||||
# instead of leaking a raw traceback through
|
||||
# resolve_and_run_event_command.
|
||||
return None
|
||||
content = template_path.read_text(encoding="utf-8")
|
||||
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
@@ -592,13 +524,7 @@ def _resolve_event_command_argv(
|
||||
else:
|
||||
base = project_root / ".specify"
|
||||
|
||||
try:
|
||||
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
|
||||
except ValueError:
|
||||
# Mirror the generated dispatcher's _resolve_argv: a scripts: value
|
||||
# shlex cannot tokenize (e.g. an unclosed quote) declares no runnable
|
||||
# script, so degrade to "no argv" instead of raising.
|
||||
return None
|
||||
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
|
||||
if not tokens:
|
||||
return None
|
||||
script_abs = base / tokens[0]
|
||||
@@ -659,26 +585,12 @@ def resolve_and_run_event_command(
|
||||
project_root: Path,
|
||||
*,
|
||||
timeout: int = 120,
|
||||
envelope: str = "plain",
|
||||
native_event: str = "",
|
||||
) -> int:
|
||||
"""Core entry point to resolve and execute an event-driven command.
|
||||
|
||||
*timeout* is the per-handler timeout in seconds, passed through from the
|
||||
native hook config via the dispatcher (S4) so a handler configured above
|
||||
the previous fixed 120s cap can run for its full duration.
|
||||
|
||||
*envelope* selects how the handler's stdout is emitted for the agent's
|
||||
context-injection protocol (C13): ``plain`` passthrough (Claude/Codex
|
||||
inject plain stdout), ``hookSpecificOutput``/``additionalContext``/
|
||||
``additional_context`` JSON wrappers (Gemini/Tabnine/Qwen/Devin, Copilot,
|
||||
Cursor respectively), or ``suppress`` (strict-JSON agents on events whose
|
||||
output can't be used).
|
||||
|
||||
*native_event* is the agent's native hookEventName (e.g. ``"SessionStart"``),
|
||||
required inside ``hookSpecificOutput`` by Qwen's hooks spec (and included
|
||||
by the Claude Code hooks spec Gemini/Tabnine/Devin derive from). Only
|
||||
used when *envelope* is ``hookSpecificOutput``.
|
||||
"""
|
||||
template_path, ext_id = _find_command_template(command_name, project_root)
|
||||
if not template_path:
|
||||
@@ -698,7 +610,7 @@ def resolve_and_run_event_command(
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.stdout:
|
||||
_emit_event_stdout(result.stdout, envelope, native_event)
|
||||
sys.stdout.write(result.stdout)
|
||||
if result.returncode != 0:
|
||||
if result.stderr:
|
||||
sys.stderr.write(result.stderr)
|
||||
@@ -712,35 +624,6 @@ def resolve_and_run_event_command(
|
||||
return 2
|
||||
|
||||
|
||||
def _emit_event_stdout(output: str, envelope: str, native_event: str = "") -> None:
|
||||
"""Write handler stdout in the agent's context-injection shape (C13).
|
||||
|
||||
Mirrors the ``_emit`` helper inside the generated dispatcher template;
|
||||
keep both in sync. Empty output emits nothing under any envelope.
|
||||
|
||||
*native_event* is the agent's native hookEventName, required inside
|
||||
``hookSpecificOutput`` by Qwen's hooks spec (and included by the
|
||||
Claude Code hooks spec Gemini/Tabnine/Devin derive from).
|
||||
"""
|
||||
if not output:
|
||||
return
|
||||
if envelope == "suppress":
|
||||
return
|
||||
if envelope == "hookSpecificOutput":
|
||||
payload = {"additionalContext": output}
|
||||
if native_event:
|
||||
payload["hookEventName"] = native_event
|
||||
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\n")
|
||||
return
|
||||
if envelope == "additionalContext":
|
||||
sys.stdout.write(json.dumps({"additionalContext": output}) + "\n")
|
||||
return
|
||||
if envelope == "additional_context":
|
||||
sys.stdout.write(json.dumps({"additional_context": output}) + "\n")
|
||||
return
|
||||
sys.stdout.write(output)
|
||||
|
||||
|
||||
# -- Sourcing events map (CLI/Orchestration domain) -------------------------
|
||||
|
||||
# Resolved events map: each canonical event name maps to an *ordered list* of
|
||||
@@ -854,10 +737,8 @@ def resolve_events(
|
||||
if override_file.exists():
|
||||
try:
|
||||
override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, UnicodeError, yaml.YAMLError):
|
||||
logger.warning(
|
||||
"Could not read or parse %s; ignoring override", override_file
|
||||
)
|
||||
except yaml.YAMLError:
|
||||
logger.warning("Could not parse %s; ignoring override", override_file)
|
||||
override = {}
|
||||
integrations = override.get("integrations", {}) if isinstance(override, dict) else {}
|
||||
if isinstance(integrations, dict) and integration_key in integrations:
|
||||
@@ -1135,17 +1016,7 @@ def _dispatcher_command(
|
||||
When *timeout_seconds* is given, the resolved timeout (in the
|
||||
integration's native unit) is appended as a 4th argument so the dispatcher
|
||||
and inner runner honor the per-handler timeout instead of a fixed 120s cap
|
||||
that would kill a handler configured for longer (S4). When omitted, a
|
||||
default of 60s is emitted so the positional argument order
|
||||
(command event timeout envelope native_event) stays aligned — otherwise
|
||||
the envelope would land in the timeout slot and the dispatcher would
|
||||
silently fall back to plain stdout.
|
||||
|
||||
When the integration declares a context-injection envelope for this
|
||||
canonical event (``events_context_envelope``, C13), the envelope token is
|
||||
appended as a 5th argument so the dispatcher wraps stdout in the JSON
|
||||
shape the agent's hook protocol requires. Plain-passthrough agents
|
||||
(Claude/Codex) declare no envelope and get no extra argument.
|
||||
that would kill a handler configured for longer (S4).
|
||||
"""
|
||||
if target_os == "host":
|
||||
interpreter = _resolve_interpreter(project_root)
|
||||
@@ -1165,44 +1036,16 @@ def _dispatcher_command(
|
||||
# operator. Prefix & for the explicit windows target only.
|
||||
prefix = "& " if target_os == "windows" else ""
|
||||
base = f"{prefix}{q_interp} {dispatcher} {q_command} {q_event}"
|
||||
# Always emit the timeout (4th positional arg) so the dispatcher's argv
|
||||
# parsing stays aligned when an envelope (5th) or native_event (6th)
|
||||
# follows. Without it the envelope would land in the timeout slot and
|
||||
# the dispatcher would fall back to plain stdout (R3).
|
||||
resolved_timeout = 60 if timeout_seconds is None else int(timeout_seconds)
|
||||
# R2: the dispatcher interprets this argument as seconds, so pass the
|
||||
# raw seconds — NOT _native_timeout(...) (which converts to ms for
|
||||
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
|
||||
# applied to the native hook timeout field (in the adapter formatters)
|
||||
# so the agent's outer cap fires after the inner subprocess timeout.
|
||||
base += f" {_shell_quote(str(resolved_timeout), target_os)}"
|
||||
envelope = _context_envelope_for(integration, event_name)
|
||||
if envelope:
|
||||
base += f" {_shell_quote(envelope, target_os)}"
|
||||
# hookSpecificOutput requires the native hookEventName inside the
|
||||
# envelope (Qwen's hooks spec marks it mandatory; the Claude Code
|
||||
# hooks spec that Gemini/Tabnine/Devin derive from includes it).
|
||||
# Append the native event name as a 6th dispatcher argument so the
|
||||
# dispatcher can populate hookEventName in the JSON output.
|
||||
if envelope == "hookSpecificOutput":
|
||||
native_event = getattr(integration, "CANONICAL_TO_NATIVE", {}).get(event_name, "")
|
||||
if native_event:
|
||||
base += f" {_shell_quote(native_event, target_os)}"
|
||||
if timeout_seconds is not None:
|
||||
# R2: the dispatcher interprets this argument as seconds, so pass the
|
||||
# raw seconds — NOT _native_timeout(...) (which converts to ms for
|
||||
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
|
||||
# applied to the native hook timeout field (in the adapter formatters)
|
||||
# so the agent's outer cap fires after the inner subprocess timeout.
|
||||
base += f" {_shell_quote(str(int(timeout_seconds)), target_os)}"
|
||||
return base
|
||||
|
||||
|
||||
def _context_envelope_for(integration: IntegrationBase, canonical_event: str) -> str | None:
|
||||
"""Resolve the context-injection envelope for an integration + event (C13).
|
||||
|
||||
The event key wins; ``"*"`` is the fallback. Returns ``None`` when the
|
||||
integration declares no envelope for the event (plain stdout passthrough).
|
||||
"""
|
||||
mapping = getattr(integration, "events_context_envelope", None) or {}
|
||||
if canonical_event in mapping:
|
||||
return mapping[canonical_event]
|
||||
return mapping.get("*")
|
||||
|
||||
|
||||
def install_integration_events(
|
||||
integration: IntegrationBase,
|
||||
project_root: Path,
|
||||
@@ -1350,12 +1193,11 @@ def install_integration_events(
|
||||
lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER)}')
|
||||
lines.append('speckit_marker = true')
|
||||
lines.append('')
|
||||
# S5: only track when the merge wrote (skips on unreadable file).
|
||||
if _merge_toml_fragment(config_path, "\n".join(lines)):
|
||||
rel = str(config_path.relative_to(project_root))
|
||||
if rel not in manifest.files:
|
||||
manifest.record_existing(rel)
|
||||
created.append(config_path)
|
||||
_merge_toml_fragment(config_path, "\n".join(lines))
|
||||
rel = str(config_path.relative_to(project_root))
|
||||
if rel not in manifest.files:
|
||||
manifest.record_existing(rel)
|
||||
created.append(config_path)
|
||||
|
||||
elif fmt == "json-flat":
|
||||
# Cursor hooks.json custom merge. Flat command-string entries, one
|
||||
@@ -1746,14 +1588,6 @@ def _build_opencode_plugin(
|
||||
an argv array (C9). Both the ``input`` and ``output`` callback arguments
|
||||
are forwarded to ``runEvent`` (C7) so pre_tool_use can inspect tool
|
||||
arguments and post_tool_use can inspect the result.
|
||||
|
||||
Context-injection natives get dedicated hook bodies: for
|
||||
``experimental.chat.system.transform`` the handlers' concatenated stdout
|
||||
is pushed into ``output.system`` (system-prompt injection, re-applied per
|
||||
LLM request so the context survives compaction); for ``chat.message`` it
|
||||
is pushed as a synthetic text part on the user message (C11). Other
|
||||
natives keep their side-effect behavior (tool.execute.* args mutation;
|
||||
session.* lifecycle events via the generic ``event`` hook).
|
||||
"""
|
||||
event_entries: list[str] = []
|
||||
plugin_returns: list[str] = []
|
||||
@@ -1768,16 +1602,11 @@ def _build_opencode_plugin(
|
||||
ev_lit = json.dumps(ev)
|
||||
native_lit = json.dumps(native)
|
||||
|
||||
is_injection = native in ("experimental.chat.system.transform", "chat.message")
|
||||
|
||||
# Build the body: one runEvent() call per handler wrapped in try/catch,
|
||||
# forwarding both input and output (C7). An optional tool-name matcher
|
||||
# guard applies to tool.execute.* hooks. All handlers execute before
|
||||
# any aggregate error is thrown. Injection hooks additionally collect
|
||||
# each handler's stdout and return the concatenation.
|
||||
# any aggregate error is thrown.
|
||||
body_lines: list[str] = [" const errors: string[] = [];"]
|
||||
if is_injection:
|
||||
body_lines.append(" const contexts: string[] = [];")
|
||||
for cfg in handlers:
|
||||
command = str(cfg.get("command", ""))
|
||||
command_lit = json.dumps(command)
|
||||
@@ -1799,10 +1628,6 @@ def _build_opencode_plugin(
|
||||
body_lines.append(
|
||||
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
|
||||
)
|
||||
elif is_injection:
|
||||
body_lines.append(
|
||||
f" try {{ const ctx = runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); if (ctx) contexts.push(ctx); }} catch (e) {{ errors.push((e as Error).message); }}"
|
||||
)
|
||||
else:
|
||||
body_lines.append(
|
||||
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
|
||||
@@ -1821,65 +1646,14 @@ def _build_opencode_plugin(
|
||||
f" _{ev}(input, output);\n"
|
||||
f" }},"
|
||||
)
|
||||
elif native == "experimental.chat.system.transform":
|
||||
body_lines.append(' return contexts.join("\\n\\n");')
|
||||
event_entries.append(
|
||||
f"function _{ev}(input: any, output: any): string {{\n"
|
||||
+ "\n".join(body_lines) + "\n"
|
||||
" }"
|
||||
)
|
||||
# OpenCode fires experimental.chat.system.transform for non-session
|
||||
# operations (e.g. agent generation) with no sessionID. Guard so
|
||||
# canonical session_start handlers only run when a session is
|
||||
# present, preventing their output from being injected into
|
||||
# internal prompts. Cache the handler output per sessionID so
|
||||
# non-idempotent handlers (setup, telemetry, file-mutating
|
||||
# scripts) execute once per session instead of on every LLM
|
||||
# request; the cache is evicted on session.deleted.
|
||||
plugin_returns.append(
|
||||
f" {native_lit}: async (input: any, output: any) => {{\n"
|
||||
f" if (!input.sessionID) return;\n"
|
||||
f" let ctx = sessionStartCache.get(input.sessionID);\n"
|
||||
f" if (ctx === undefined) {{\n"
|
||||
f" ctx = _{ev}(input, output);\n"
|
||||
f" sessionStartCache.set(input.sessionID, ctx ?? \"\");\n"
|
||||
f" }}\n"
|
||||
f" if (ctx) output.system.push(ctx);\n"
|
||||
f" }},"
|
||||
)
|
||||
elif native == "chat.message":
|
||||
body_lines.append(' return contexts.join("\\n\\n");')
|
||||
event_entries.append(
|
||||
f"function _{ev}(input: any, output: any): string {{\n"
|
||||
+ "\n".join(body_lines) + "\n"
|
||||
" }"
|
||||
)
|
||||
# Part id must start with "prt" (opencode's Identifier brand): an
|
||||
# invalid id fails the user-part schema validation and crashes the
|
||||
# whole session (C12). Derive it from the last existing part so the
|
||||
# brand survives an opencode prefix change, falling back to "prt_"
|
||||
# when output.parts is empty.
|
||||
plugin_returns.append(
|
||||
f" {native_lit}: async (input: any, output: any) => {{\n"
|
||||
f" const ctx = _{ev}(input, output);\n"
|
||||
f" if (!ctx) return;\n"
|
||||
f" const base = output.parts[output.parts.length - 1]?.id ?? \"prt_\" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);\n"
|
||||
f" output.parts.push({{ id: base + \".speckit\" + Math.random().toString(36).slice(2, 8), sessionID: input.sessionID, messageID: output.message.id, type: \"text\", text: ctx, synthetic: true }});\n"
|
||||
f" }},"
|
||||
)
|
||||
else:
|
||||
event_entries.append(
|
||||
f"function _{ev}(input: any, output: any) {{\n"
|
||||
+ "\n".join(body_lines) + "\n"
|
||||
" }"
|
||||
)
|
||||
# Evict the sessionStartCache when the session is deleted so the
|
||||
# cache doesn't grow unbounded across sessions.
|
||||
eviction = ""
|
||||
if native == "session.deleted":
|
||||
eviction = "if (event.sessionID) sessionStartCache.delete(event.sessionID); "
|
||||
event_handlers.append(
|
||||
f" if (event.type === {native_lit}) {{ {eviction}_{ev}(event, event); }}"
|
||||
f" if (event.type === {native_lit}) {{ _{ev}(event, event); }}"
|
||||
)
|
||||
|
||||
if event_handlers:
|
||||
@@ -1941,27 +1715,11 @@ def _remove_opencode_entries(config_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
|
||||
"""Merge Specify-owned TOML entries into *dst*, regenerating the file.
|
||||
|
||||
An unreadable or undecodable pre-existing file aborts the merge instead
|
||||
of discarding the user's bytes, mirroring ``_load_user_json`` (#22).
|
||||
Returns False when skipped so callers avoid tracking the untouched file
|
||||
(S5).
|
||||
"""
|
||||
def _merge_toml_fragment(dst: Path, fragment: str) -> None:
|
||||
_ensure_safe_destination(dst)
|
||||
existing = ""
|
||||
if dst.exists():
|
||||
try:
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
logger.warning(
|
||||
"Could not read %s (it may be unreadable or not UTF-8); "
|
||||
"skipping event-config merge to preserve user content.",
|
||||
dst,
|
||||
)
|
||||
logger.debug("Read error detail: %s", exc)
|
||||
return False
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
existing = re.sub(
|
||||
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
|
||||
"",
|
||||
@@ -1970,7 +1728,6 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
|
||||
)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def _remove_toml_entries(dst: Path) -> bool:
|
||||
@@ -1984,19 +1741,7 @@ def _remove_toml_entries(dst: Path) -> bool:
|
||||
# the config after install can't make teardown overwrite a file outside
|
||||
# the project (the merge/write path already validates; teardown must too).
|
||||
_ensure_safe_destination(dst)
|
||||
try:
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
# An unreadable or undecodable file is left untouched rather than
|
||||
# crashing teardown — it contains only user content as far as we can
|
||||
# tell, and the caller drops the manifest claim either way (S9).
|
||||
logger.warning(
|
||||
"Could not read %s (it may be unreadable or not UTF-8); "
|
||||
"skipping event-config cleanup to preserve user content.",
|
||||
dst,
|
||||
)
|
||||
logger.debug("Read error detail: %s", exc)
|
||||
return False
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
cleaned = re.sub(
|
||||
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
|
||||
"",
|
||||
|
||||
@@ -341,25 +341,6 @@ class ExtensionManifest:
|
||||
)
|
||||
if "speckit_version" not in requires:
|
||||
raise ValidationError("Missing requires.speckit_version")
|
||||
# Presence alone is not enough: check_compatibility() feeds this value to
|
||||
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
|
||||
# which a non-string escapes two different ways. A float/int/bool/None
|
||||
# raises TypeError from the constructor, while a list or dict is an
|
||||
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
|
||||
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
|
||||
# inside .contains(). Neither is a CompatibilityError, so both bypass the
|
||||
# CLI's "Compatibility Error" handler and exit 1 with a raw traceback
|
||||
# naming no field. An unquoted ``speckit_version: 1.0`` is an easy YAML
|
||||
# slip. Mirrors the sibling IntegrationDescriptor, which already requires
|
||||
# a non-empty string here.
|
||||
if (
|
||||
not isinstance(requires["speckit_version"], str)
|
||||
or not requires["speckit_version"].strip()
|
||||
):
|
||||
raise ValidationError(
|
||||
"Invalid requires.speckit_version: expected a non-empty string, "
|
||||
f"got {type(requires['speckit_version']).__name__}"
|
||||
)
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
@@ -660,13 +641,8 @@ class ExtensionRegistry:
|
||||
if not isinstance(data.get("extensions"), dict):
|
||||
data["extensions"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError):
|
||||
# Corrupted or missing registry, start fresh. A registry whose
|
||||
# bytes cannot be decoded as UTF-8 is the same corruption class as
|
||||
# malformed JSON — only the exception type differs, and it is
|
||||
# raised by the text-mode read before JSON parsing begins. OSError
|
||||
# is deliberately not caught: the data may be intact on disk, and
|
||||
# starting fresh would let a later _save() wipe it.
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
# Corrupted or missing registry, start fresh
|
||||
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
|
||||
|
||||
def _save(self):
|
||||
@@ -1875,17 +1851,6 @@ class ExtensionManager:
|
||||
required = manifest.requires_speckit_version
|
||||
|
||||
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
|
||||
# Defense in depth: the manifest validator now rejects a non-string
|
||||
# requires.speckit_version, but this method is public and also reachable
|
||||
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
|
||||
# cover a non-string -- scalars raise TypeError from the constructor, and
|
||||
# a list/dict is iterable so it constructs here and only breaks inside
|
||||
# .contains(). Reject up front so this always reports a CompatibilityError.
|
||||
if not isinstance(required, str):
|
||||
raise CompatibilityError(
|
||||
"Invalid version specifier: expected a string, got "
|
||||
f"{type(required).__name__} ({required!r})"
|
||||
)
|
||||
try:
|
||||
SpecifierSet(required) # Just to validate
|
||||
except InvalidSpecifier:
|
||||
@@ -2128,19 +2093,8 @@ class ExtensionManager:
|
||||
_staged_modes = _loaded_modes
|
||||
for staged_name in sorted(staged_names):
|
||||
staged_file = rescue_staging_dir / staged_name
|
||||
# A staged backup that cannot be read or stat'ed must not
|
||||
# crash the retry with a raw OSError: like an uncomparable
|
||||
# live config below, treat it as a conflict so both copies
|
||||
# are preserved and the user resolves it while dest_dir is
|
||||
# still untouched. Every sibling read in this path (live
|
||||
# twin, packaged baseline, mode sidecar) already catches
|
||||
# OSError.
|
||||
try:
|
||||
staged_stat = staged_file.stat()
|
||||
staged_bytes = staged_file.read_bytes()
|
||||
except OSError:
|
||||
conflicting.add(staged_name)
|
||||
continue
|
||||
staged_stat = staged_file.stat()
|
||||
staged_bytes = staged_file.read_bytes()
|
||||
# Prefer the sidecar-recorded mode; fall back to the staged
|
||||
# file's own mode for backwards-compat with staging dirs
|
||||
# written before the sidecar was introduced.
|
||||
@@ -2232,24 +2186,10 @@ class ExtensionManager:
|
||||
"a regular file or remove it — then reinstall."
|
||||
)
|
||||
if cfg_file.is_file():
|
||||
# A kept config that cannot be read or stat'ed must not
|
||||
# crash the reinstall with a raw OSError — and must not
|
||||
# reach the rmtree below unrescued. Like the symlink
|
||||
# guard above, reject while dest_dir is untouched so the
|
||||
# preserved bytes are never lost.
|
||||
try:
|
||||
stranded_configs[cfg_file.name] = (
|
||||
cfg_file.read_bytes(),
|
||||
cfg_file.stat().st_mode,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ValidationError(
|
||||
"Preserved extension config for "
|
||||
f"'{manifest.id}' cannot be read "
|
||||
f"({cfg_file.name}) in {dest_dir}: {exc}. "
|
||||
"Resolve manually — fix its permissions or "
|
||||
"remove it — then reinstall."
|
||||
) from exc
|
||||
stranded_configs[cfg_file.name] = (
|
||||
cfg_file.read_bytes(),
|
||||
cfg_file.stat().st_mode,
|
||||
)
|
||||
|
||||
if stranded_configs and not staging_is_complete:
|
||||
# Write a durable backup outside dest_dir before any
|
||||
@@ -4315,10 +4255,11 @@ class ConfigManager:
|
||||
Returns an empty list if the registry is missing or corrupted
|
||||
(fresh project, ad-hoc test harness) so ``_get_env_config`` degrades
|
||||
to its pre-fix behaviour rather than crashing. ``UnicodeError`` is
|
||||
kept alongside ``OSError`` as belt-and-braces: ``_load()`` now starts
|
||||
fresh on non-UTF-8 registry bytes itself, but catching it here too
|
||||
keeps this call site degrading gracefully rather than breaking *every*
|
||||
config read if that handling ever regresses.
|
||||
caught alongside ``OSError`` because ``ExtensionRegistry._load()``
|
||||
opens the file in text mode and only handles ``JSONDecodeError`` /
|
||||
``FileNotFoundError``, so a registry file with non-UTF-8 bytes would
|
||||
otherwise surface a ``UnicodeDecodeError`` here and break *every*
|
||||
config read instead of degrading gracefully.
|
||||
|
||||
Used by ``_get_env_config`` to detect env vars whose remainder claims
|
||||
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
|
||||
|
||||
@@ -70,8 +70,8 @@ def with_integration_setting(
|
||||
# ``script_type`` changes (``parsed_options`` and ``raw_options`` both
|
||||
# None), the previously-stored ``parsed_options`` are retained above, so
|
||||
# deriving the separator from the argument (None) would drop an
|
||||
# options-dependent separator (e.g. Copilot ``--commands`` -> ".") back to
|
||||
# the default "-".
|
||||
# options-dependent separator (e.g. Copilot ``--skills`` -> "-") back to
|
||||
# the default ".".
|
||||
current["invoke_separator"] = integration.effective_invoke_separator(
|
||||
current.get("parsed_options"), project_root
|
||||
)
|
||||
|
||||
@@ -331,14 +331,6 @@ def integration_switch(
|
||||
|
||||
selected_script = _resolve_script_type(project_root, script)
|
||||
|
||||
# Resolve and validate target options before uninstalling the current
|
||||
# integration. Invalid options must not leave the project partially
|
||||
# switched with the previous integration already removed.
|
||||
target_raw_options, target_parsed_options = _resolve_integration_options(
|
||||
target_integration, current, target, integration_options
|
||||
)
|
||||
target_integration.is_skills_mode(target_parsed_options, project_root)
|
||||
|
||||
# Phase 1: Uninstall current integration (if any)
|
||||
if installed_key:
|
||||
current_integration = get_integration(installed_key)
|
||||
@@ -411,10 +403,7 @@ def integration_switch(
|
||||
fallback_key = installed_keys[0]
|
||||
fallback_integration = get_integration(fallback_key)
|
||||
if fallback_integration is not None:
|
||||
(
|
||||
fallback_raw_options,
|
||||
fallback_parsed_options,
|
||||
) = _resolve_integration_options(
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
fallback_integration, current, fallback_key, None
|
||||
)
|
||||
_set_default_integration_or_exit(
|
||||
@@ -423,8 +412,8 @@ def integration_switch(
|
||||
fallback_key,
|
||||
fallback_integration,
|
||||
installed_keys,
|
||||
raw_options=fallback_raw_options,
|
||||
parsed_options=fallback_parsed_options,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
else:
|
||||
_write_integration_json(
|
||||
@@ -434,6 +423,13 @@ def integration_switch(
|
||||
_remove_integration_json(project_root)
|
||||
current = _read_integration_json(project_root)
|
||||
|
||||
# Build parsed options from --integration-options so the integration
|
||||
# can determine its effective invoke separator before shared infra
|
||||
# is installed.
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
target_integration, current, target, integration_options
|
||||
)
|
||||
|
||||
# Refresh shared infrastructure to the current CLI version. Switching
|
||||
# integrations is exactly when stale vendored shared scripts (e.g.
|
||||
# update-agent-context.sh that pre-dates the target integration's
|
||||
@@ -449,11 +445,11 @@ def integration_switch(
|
||||
force=refresh_shared_infra,
|
||||
refresh_managed=True,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
target_integration, current, target, target_parsed_options,
|
||||
target_integration, current, target, parsed_options,
|
||||
project_root=project_root,
|
||||
),
|
||||
invoke_prefix=_invoke_prefix_for_integration(
|
||||
target_integration, target, target_parsed_options, project_root
|
||||
target_integration, target, parsed_options, project_root
|
||||
),
|
||||
refresh_hint=(
|
||||
"To overwrite customizations, re-run with "
|
||||
@@ -475,14 +471,14 @@ def integration_switch(
|
||||
target_integration.key,
|
||||
target_integration.config,
|
||||
project_root,
|
||||
target_parsed_options,
|
||||
parsed_options,
|
||||
)
|
||||
try:
|
||||
target_integration.setup(
|
||||
project_root, manifest,
|
||||
parsed_options=target_parsed_options,
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=target_raw_options,
|
||||
raw_options=raw_options,
|
||||
events=events_map,
|
||||
)
|
||||
manifest.save()
|
||||
@@ -493,8 +489,8 @@ def integration_switch(
|
||||
target_integration,
|
||||
_dedupe_integration_keys([*installed_keys, target_integration.key]),
|
||||
script_type=selected_script,
|
||||
raw_options=target_raw_options,
|
||||
parsed_options=target_parsed_options,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
|
||||
@@ -225,8 +225,8 @@ class IntegrationBase(ABC):
|
||||
on-disk layout to avoid silently migrating an existing project to a
|
||||
different mode. The default ignores it.
|
||||
|
||||
The default for command-first integrations is skills mode only when
|
||||
``--skills`` was requested.
|
||||
The default (command-first integrations, e.g. Copilot's default
|
||||
layout) is skills mode only when ``--skills`` was requested.
|
||||
``SkillsIntegration`` overrides this to return ``True`` by default;
|
||||
skills-first integrations that expose a legacy opt-out (e.g. Bob)
|
||||
override it to honor their own flag.
|
||||
@@ -957,20 +957,6 @@ class IntegrationBase(ABC):
|
||||
"""Return True if this integration supports agent-native events."""
|
||||
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
|
||||
|
||||
# Context-injection envelope for hook stdout, keyed by canonical event
|
||||
# (with "*" as the fallback). Not every agent injects a hook's plain-text
|
||||
# stdout as model context: Gemini/Tabnine/Qwen/Devin are JSON-only
|
||||
# protocols (plain text becomes user-facing noise), Copilot discards
|
||||
# non-JSON stdout, and Cursor parses stdout as JSON. Values:
|
||||
# "hookSpecificOutput" → {"hookSpecificOutput": {"additionalContext": ...}}
|
||||
# "additionalContext" → {"additionalContext": ...} (top-level, Copilot)
|
||||
# "additional_context" → {"additional_context": ...} (top-level, Cursor)
|
||||
# "suppress" → emit nothing (strict-JSON agents on events whose
|
||||
# output can't be used)
|
||||
# Absent (no matching key and no "*") → plain stdout passthrough
|
||||
# (Claude/Codex inject plain stdout; opencode injects via its TS plugin).
|
||||
events_context_envelope: dict[str, str] = {}
|
||||
|
||||
# -- Convenience helpers for subclasses -------------------------------
|
||||
|
||||
def install(
|
||||
|
||||
@@ -207,7 +207,7 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
max_bytes=MAX_JSON_METADATA_BYTES,
|
||||
error_type=IntegrationCatalogError,
|
||||
label=f"catalog from {entry.url}",
|
||||
).decode("utf-8")
|
||||
)
|
||||
)
|
||||
|
||||
shape_error = _catalog_shape_error(catalog_data)
|
||||
@@ -237,15 +237,6 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
raise IntegrationCatalogError(
|
||||
f"Failed to fetch catalog from {entry.url}: {exc}"
|
||||
)
|
||||
except UnicodeDecodeError as exc:
|
||||
# A non-UTF-8 response body fails at .decode() before json.loads()
|
||||
# ever runs, so JSONDecodeError below does not cover it (the two are
|
||||
# sibling ValueError subclasses, not parent/child). Without this the
|
||||
# raw UnicodeDecodeError escapes _get_merged_integrations()'s
|
||||
# "warn and skip this catalog" handler and kills the whole command.
|
||||
raise IntegrationCatalogError(
|
||||
f"Catalog from {entry.url} is not valid UTF-8: {exc}"
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid JSON in catalog from {entry.url}: {exc}"
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
"""Copilot integration — GitHub Copilot in VS Code.
|
||||
|
||||
Copilot supports two layouts:
|
||||
- Skills are the default and use ``speckit-<name>/SKILL.md`` directories under
|
||||
``.github/skills/``
|
||||
- ``--commands`` uses ``.agent.md`` files, companion ``.prompt.md`` files, and
|
||||
a VS Code settings merge
|
||||
|
||||
The two modes are mutually exclusive. The commands layout remains supported,
|
||||
but is no longer the preferred default.
|
||||
|
||||
The commands layout has several unique behaviors compared to standard markdown
|
||||
agents:
|
||||
Copilot has several unique behaviors compared to standard markdown agents:
|
||||
- Commands use ``.agent.md`` extension (not ``.md``)
|
||||
- Each command gets a companion ``.prompt.md`` file in ``.github/prompts/``
|
||||
- Installs ``.vscode/settings.json`` with prompt file recommendations
|
||||
|
||||
When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds
|
||||
commands as ``speckit-<name>/SKILL.md`` directories under ``.github/skills/``
|
||||
instead. The two modes are mutually exclusive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,24 +19,9 @@ import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
|
||||
from ..base import IntegrationBase, IntegrationOption, SkillsIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
_COPILOT_CORE_COMMANDS = {
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"converge",
|
||||
"implement",
|
||||
"plan",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
}
|
||||
|
||||
|
||||
def _copilot_executable() -> str:
|
||||
"""Return the executable name for Copilot CLI on this platform.
|
||||
@@ -78,24 +57,22 @@ def _allow_all() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None:
|
||||
"""Reject the two explicit Copilot layout selectors used together."""
|
||||
opts = parsed_options or {}
|
||||
if opts.get("skills") and opts.get("commands"):
|
||||
from ..._console import console
|
||||
|
||||
console.print(
|
||||
"[red]Error:[/red] --skills and --commands are mutually exclusive; "
|
||||
"pass only one."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
def _warn_legacy_markdown_default() -> None:
|
||||
"""Warn that Copilot's default markdown scaffold is being phased out."""
|
||||
warnings.warn(
|
||||
"Copilot legacy markdown mode is deprecated and will stop being the "
|
||||
'default in a future Spec Kit release; pass --integration-options "--skills" '
|
||||
"to opt in to Copilot skills mode now.",
|
||||
UserWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
|
||||
class _CopilotSkillsHelper(SkillsIntegration):
|
||||
"""Internal helper used when Copilot is scaffolded in skills mode.
|
||||
|
||||
Not registered in the integration registry — only used as the default
|
||||
skills-layout delegate by ``CopilotIntegration``.
|
||||
Not registered in the integration registry — only used as a delegate
|
||||
by ``CopilotIntegration`` when ``--skills`` is passed.
|
||||
"""
|
||||
|
||||
key = "copilot"
|
||||
@@ -117,11 +94,13 @@ class _CopilotSkillsHelper(SkillsIntegration):
|
||||
class CopilotIntegration(IntegrationBase):
|
||||
"""Integration for GitHub Copilot (VS Code IDE + CLI).
|
||||
|
||||
The default IDE integration (``requires_cli: False``) installs skills under
|
||||
``.github/skills/``. Pass ``--commands`` via ``--integration-options`` to
|
||||
install the supported ``.agent.md`` + ``.prompt.md`` layout instead.
|
||||
Workflow dispatch additionally requires the ``copilot`` CLI to be installed
|
||||
separately.
|
||||
The IDE integration (``requires_cli: False``) installs ``.agent.md``
|
||||
command files. Workflow dispatch additionally requires the
|
||||
``copilot`` CLI to be installed separately.
|
||||
|
||||
When ``--skills`` is passed via ``--integration-options``, commands
|
||||
are scaffolded as ``speckit-<name>/SKILL.md`` under ``.github/skills/``
|
||||
instead of the default ``.agent.md`` + ``.prompt.md`` layout.
|
||||
"""
|
||||
|
||||
key = "copilot"
|
||||
@@ -138,7 +117,6 @@ class CopilotIntegration(IntegrationBase):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".agent.md",
|
||||
}
|
||||
invoke_separator = "-"
|
||||
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"session_start": "sessionStart",
|
||||
@@ -152,101 +130,40 @@ class CopilotIntegration(IntegrationBase):
|
||||
}
|
||||
events_config_file = ".github/hooks/speckit.json"
|
||||
events_format = "copilot-json"
|
||||
# Copilot sessionStart and userPromptSubmitted inject a top-level
|
||||
# additionalContext field into the model-facing prompt (C13). Non-JSON
|
||||
# stdout is discarded harmlessly by Copilot on other events, so no other
|
||||
# event needs an envelope.
|
||||
events_context_envelope = {
|
||||
"session_start": "additionalContext",
|
||||
"user_prompt_submit": "additionalContext",
|
||||
}
|
||||
|
||||
# Mutable flag set by setup() — indicates the active scaffolding mode.
|
||||
_skills_mode: bool = True
|
||||
_skills_mode: bool = False
|
||||
|
||||
def effective_invoke_separator(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> str:
|
||||
"""Return the separator for the resolved Copilot layout."""
|
||||
return "-" if self.is_skills_mode(parsed_options, project_root) else "."
|
||||
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
|
||||
if parsed_options and parsed_options.get("skills"):
|
||||
return "-"
|
||||
if self._skills_mode:
|
||||
return "-"
|
||||
return self.invoke_separator
|
||||
|
||||
def is_skills_mode(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> bool:
|
||||
"""Copilot defaults to skills; ``--commands`` opts into commands mode.
|
||||
"""Copilot is skills mode when ``--skills`` was requested.
|
||||
|
||||
Explicit flags override on-disk detection. Without a flag, existing
|
||||
projects retain their managed Spec Kit layout while fresh projects use
|
||||
skills. This prevents ``use`` and ``upgrade`` from silently migrating
|
||||
projects created before skills became the default.
|
||||
On the init path ``setup()`` has already recorded the choice in
|
||||
``self._skills_mode``; on the ``use``/``install`` path (where no
|
||||
``setup()`` runs) the signal comes from *parsed_options* (#3550), which
|
||||
round-trips because ``--skills`` is persisted in the stored options.
|
||||
"""
|
||||
opts = parsed_options or {}
|
||||
_validate_mode_options(opts)
|
||||
if opts.get("skills"):
|
||||
if parsed_options and parsed_options.get("skills"):
|
||||
return True
|
||||
if opts.get("commands"):
|
||||
return False
|
||||
if project_root is not None:
|
||||
project_root = Path(project_root)
|
||||
manifest_path = (
|
||||
project_root
|
||||
/ ".specify"
|
||||
/ "integrations"
|
||||
/ "copilot.manifest.json"
|
||||
)
|
||||
if manifest_path.is_file():
|
||||
try:
|
||||
manifest_files = IntegrationManifest.load(
|
||||
self.key, Path(project_root)
|
||||
).files
|
||||
except (OSError, ValueError):
|
||||
manifest_files = None
|
||||
if manifest_files is not None and any(
|
||||
path.startswith(".github/skills/speckit-")
|
||||
and path.endswith("/SKILL.md")
|
||||
for path in manifest_files
|
||||
):
|
||||
return True
|
||||
if manifest_files is not None and any(
|
||||
path.startswith(".github/agents/speckit.")
|
||||
and path.endswith(".agent.md")
|
||||
for path in manifest_files
|
||||
):
|
||||
return False
|
||||
|
||||
github_dir = project_root / ".github"
|
||||
has_managed_skills = any(
|
||||
(
|
||||
github_dir
|
||||
/ "skills"
|
||||
/ f"speckit-{command}"
|
||||
/ "SKILL.md"
|
||||
).is_file()
|
||||
for command in _COPILOT_CORE_COMMANDS
|
||||
)
|
||||
has_managed_commands = any(
|
||||
(
|
||||
github_dir
|
||||
/ "agents"
|
||||
/ f"speckit.{command}.agent.md"
|
||||
).is_file()
|
||||
or (
|
||||
github_dir
|
||||
/ "prompts"
|
||||
/ f"speckit.{command}.prompt.md"
|
||||
).is_file()
|
||||
for command in _COPILOT_CORE_COMMANDS
|
||||
)
|
||||
if has_managed_commands and not has_managed_skills:
|
||||
return False
|
||||
return True
|
||||
return self._skills_mode
|
||||
|
||||
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
|
||||
"""Skills projects render ``/speckit-<cmd>``; commands use ``.``.
|
||||
"""Skills projects render ``/speckit-<cmd>``; default markdown ``.``.
|
||||
|
||||
Copilot is dual-layout, so — like Bob — the command-reference
|
||||
separator depends on the persisted ``ai_skills`` state rather than a
|
||||
@@ -254,7 +171,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
Copilot skills project consistent with ``build_command_invocation``
|
||||
(which emits ``/speckit-<stem>``).
|
||||
"""
|
||||
return "-" if skills_enabled else "."
|
||||
return "-" if skills_enabled else self.invoke_separator
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
@@ -267,22 +184,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Force the default skills layout (.github/skills/), "
|
||||
"overriding on-disk auto-detection"
|
||||
),
|
||||
),
|
||||
)
|
||||
opts.append(
|
||||
IntegrationOption(
|
||||
"--commands",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Scaffold .github/agents/*.agent.md commands with companion "
|
||||
".github/prompts/*.prompt.md files instead of the default "
|
||||
"skills layout"
|
||||
),
|
||||
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .agent.md files",
|
||||
),
|
||||
)
|
||||
return opts
|
||||
@@ -326,8 +228,8 @@ class CopilotIntegration(IntegrationBase):
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Build the native invocation for a Copilot command.
|
||||
|
||||
Commands mode: agents are not slash-commands — return args as prompt.
|
||||
Skills mode (default): ``/speckit-<stem>`` slash-command dispatch.
|
||||
Default mode: agents are not slash-commands — return args as prompt.
|
||||
Skills mode: ``/speckit-<stem>`` slash-command dispatch.
|
||||
"""
|
||||
if self._skills_mode:
|
||||
stem = command_name
|
||||
@@ -364,11 +266,15 @@ class CopilotIntegration(IntegrationBase):
|
||||
if stem.startswith("speckit."):
|
||||
stem = stem[len("speckit."):]
|
||||
|
||||
skills_mode = (
|
||||
self.is_skills_mode(project_root=project_root)
|
||||
if project_root
|
||||
else self._skills_mode
|
||||
)
|
||||
# Detect skills mode from project layout when not set via setup()
|
||||
skills_mode = self._skills_mode
|
||||
if not skills_mode and project_root:
|
||||
skills_dir = project_root / ".github" / "skills"
|
||||
if skills_dir.is_dir():
|
||||
skills_mode = any(
|
||||
d.is_dir() and (d / "SKILL.md").is_file()
|
||||
for d in skills_dir.glob("speckit-*")
|
||||
)
|
||||
|
||||
if skills_mode:
|
||||
prompt = "/speckit-" + stem.replace(".", "-")
|
||||
@@ -460,18 +366,20 @@ class CopilotIntegration(IntegrationBase):
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Copilot skills or the opt-in commands layout.
|
||||
"""Install copilot commands, companion prompts, and VS Code settings.
|
||||
|
||||
Skills are the default. ``parsed_options["commands"]`` selects
|
||||
``.agent.md`` files, companion prompts, and the VS Code settings merge.
|
||||
Existing managed command layouts are preserved when no mode is explicit.
|
||||
When ``parsed_options["skills"]`` is truthy, delegates to skills
|
||||
scaffolding (``speckit-<name>/SKILL.md`` under ``.github/skills/``).
|
||||
Otherwise uses the default ``.agent.md`` + ``.prompt.md`` layout.
|
||||
"""
|
||||
parsed_options = parsed_options or {}
|
||||
self._skills_mode = self.is_skills_mode(parsed_options, project_root)
|
||||
self._skills_mode = bool(parsed_options.get("skills"))
|
||||
if self._skills_mode:
|
||||
created = self._setup_skills(project_root, manifest, parsed_options, **opts)
|
||||
else:
|
||||
created = self._setup_commands(project_root, manifest, parsed_options, **opts)
|
||||
if "skills" not in parsed_options:
|
||||
_warn_legacy_markdown_default()
|
||||
created = self._setup_default(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
# Install agent runtime events
|
||||
event_files = self.emit_events(
|
||||
@@ -480,14 +388,14 @@ class CopilotIntegration(IntegrationBase):
|
||||
created.extend(event_files)
|
||||
return created
|
||||
|
||||
def _setup_commands(
|
||||
def _setup_default(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Commands mode: .agent.md + .prompt.md + VS Code settings merge."""
|
||||
"""Default mode: .agent.md + .prompt.md + VS Code settings merge."""
|
||||
project_root_resolved = project_root.resolve()
|
||||
if manifest.project_root != project_root_resolved:
|
||||
raise ValueError(
|
||||
|
||||
@@ -48,14 +48,6 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
}
|
||||
events_config_file = ".cursor/hooks.json"
|
||||
events_format = "json-flat"
|
||||
# Cursor sessionStart injects a top-level additional_context (snake_case)
|
||||
# field (C13). beforeSubmitPrompt has no context output field (block/allow
|
||||
# only), and plain text on any hook fails Cursor's JSON parse — suppress
|
||||
# everything else.
|
||||
events_context_envelope = {
|
||||
"*": "suppress",
|
||||
"session_start": "additional_context",
|
||||
}
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
|
||||
@@ -44,13 +44,6 @@ class DevinIntegration(SkillsIntegration):
|
||||
# top-level "hooks" wrapper (U2), unlike the settings.json formats. The
|
||||
# json-root-nested writer/remover operate directly on the root event keys.
|
||||
events_format = "json-root-nested"
|
||||
# Devin's hooks protocol is JSON-stdout; additionalContext is the
|
||||
# documented injection field for SessionStart/UserPromptSubmit (C13).
|
||||
events_context_envelope = {
|
||||
"*": "suppress",
|
||||
"session_start": "hookSpecificOutput",
|
||||
"user_prompt_submit": "hookSpecificOutput",
|
||||
}
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
|
||||
@@ -33,15 +33,6 @@ class GeminiIntegration(TomlIntegration):
|
||||
}
|
||||
events_config_file = ".gemini/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Gemini mandates JSON-only hook stdout ("silence is mandatory"): plain
|
||||
# text becomes a user-facing systemMessage, never context. Inject via
|
||||
# hookSpecificOutput.additionalContext on the two context events and
|
||||
# suppress stdout everywhere else (C13).
|
||||
events_context_envelope = {
|
||||
"*": "suppress",
|
||||
"session_start": "hookSpecificOutput",
|
||||
"user_prompt_submit": "hookSpecificOutput",
|
||||
}
|
||||
# Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
|
||||
# which use seconds. The shared formatter converts via _native_timeout (#7)
|
||||
# so the default 60s becomes 60000ms instead of terminating the dispatcher
|
||||
|
||||
@@ -121,27 +121,13 @@ class HermesIntegration(SkillsIntegration):
|
||||
command_name = src_file.stem # e.g. "plan"
|
||||
skill_name = f"speckit-{command_name.replace('.', '-')}"
|
||||
|
||||
# Parse frontmatter for description. Locate the closing ``---`` on
|
||||
# its own line rather than with ``raw.split("---", 2)`` — a bare
|
||||
# substring split stops at the first ``---`` *anywhere*, including
|
||||
# one inside a value such as ``description: Separate sections
|
||||
# with ---``, which truncates the frontmatter and drops later keys.
|
||||
# The block between the delimiters is parsed unstripped so trailing
|
||||
# newlines in literal (``|``) block scalars survive.
|
||||
# Parse frontmatter for description
|
||||
frontmatter: dict[str, Any] = {}
|
||||
if raw.startswith("---"):
|
||||
fm_lines = raw.splitlines(keepends=True)
|
||||
fm_close = next(
|
||||
(
|
||||
i
|
||||
for i in range(1, len(fm_lines))
|
||||
if fm_lines[i].rstrip() == "---"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if fm_close is not None:
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
fm = yaml.safe_load("".join(fm_lines[1:fm_close]))
|
||||
fm = yaml.safe_load(parts[1])
|
||||
if isinstance(fm, dict):
|
||||
frontmatter = fm
|
||||
except yaml.YAMLError:
|
||||
@@ -157,26 +143,10 @@ class HermesIntegration(SkillsIntegration):
|
||||
project_root=project_root,
|
||||
)
|
||||
# Strip the processed frontmatter — we rebuild it for skills.
|
||||
# Scan for the closing ``---`` on its own line rather than
|
||||
# ``split("---", 2)`` so a ``---`` embedded in a value does not
|
||||
# truncate the frontmatter and spill it into the body.
|
||||
if processed_body.startswith("---"):
|
||||
body_lines = processed_body.splitlines(keepends=True)
|
||||
close_idx = next(
|
||||
(
|
||||
i
|
||||
for i in range(1, len(body_lines))
|
||||
if body_lines[i].rstrip() == "---"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if close_idx is not None:
|
||||
# Keep whatever trails the ``---`` marker on the closing
|
||||
# line so the body stays byte-for-byte identical to
|
||||
# ``split("---", 2)[2]`` for well-formed templates.
|
||||
processed_body = body_lines[close_idx][3:] + "".join(
|
||||
body_lines[close_idx + 1 :]
|
||||
)
|
||||
parts = processed_body.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
processed_body = parts[2]
|
||||
|
||||
# Select description
|
||||
description = frontmatter.get("description", "")
|
||||
|
||||
@@ -323,24 +323,14 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool:
|
||||
if not content.startswith("---"):
|
||||
return False
|
||||
|
||||
# Locate the closing ``---`` on its own line rather than with
|
||||
# ``content.split("---", 2)`` — a bare substring split stops at the first
|
||||
# ``---`` *anywhere*, including one inside a value such as
|
||||
# ``description: Separate sections with ---``, which truncates the parsed
|
||||
# frontmatter and can drop the metadata block this check relies on (so a
|
||||
# Speckit-generated skill would not be recognized on teardown).
|
||||
lines = content.splitlines(keepends=True)
|
||||
close_idx = next(
|
||||
(i for i in range(1, len(lines)) if lines[i].rstrip() == "---"),
|
||||
None,
|
||||
)
|
||||
if close_idx is None:
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return False
|
||||
|
||||
try:
|
||||
import yaml
|
||||
|
||||
frontmatter = yaml.safe_load("".join(lines[1:close_idx]))
|
||||
frontmatter = yaml.safe_load(parts[1])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -451,7 +451,8 @@ class IntegrationManifest:
|
||||
_ensure_safe_manifest_destination(self.project_root, path)
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -23,15 +23,7 @@ class OpencodeIntegration(MarkdownIntegration):
|
||||
CANONICAL_TO_NATIVE = {
|
||||
"pre_tool_use": "tool.execute.before",
|
||||
"post_tool_use": "tool.execute.after",
|
||||
# session_start maps to the system-prompt transform hook (not the
|
||||
# session.created event) so the handler's stdout is injected into the
|
||||
# system prompt — session.created has no output channel. The hook
|
||||
# fires per LLM request, which keeps the context present across
|
||||
# compaction at the cost of running the handler per turn.
|
||||
"session_start": "experimental.chat.system.transform",
|
||||
# user_prompt_submit maps to chat.message so handler stdout is
|
||||
# injected as a synthetic text part on the user's message.
|
||||
"user_prompt_submit": "chat.message",
|
||||
"session_start": "session.created",
|
||||
"session_end": "session.deleted",
|
||||
}
|
||||
events_config_file = "opencode.json"
|
||||
|
||||
@@ -30,12 +30,6 @@ class QwenIntegration(MarkdownIntegration):
|
||||
}
|
||||
events_config_file = ".qwen/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Qwen hooks are a JSON stdin/stdout protocol (Gemini-derived) (C13).
|
||||
events_context_envelope = {
|
||||
"*": "suppress",
|
||||
"session_start": "hookSpecificOutput",
|
||||
"user_prompt_submit": "hookSpecificOutput",
|
||||
}
|
||||
# Qwen Code's command hooks measure timeout in milliseconds (default
|
||||
# 60000), per the Qwen Code hooks documentation. Declaring the unit makes
|
||||
# the shared formatter convert the 60s default to 60000ms instead of
|
||||
|
||||
@@ -33,12 +33,6 @@ class TabnineIntegration(TomlIntegration):
|
||||
}
|
||||
events_config_file = ".tabnine/agent/settings.json"
|
||||
events_format = "json-nested"
|
||||
# Tabnine is Gemini-hooks-compatible (JSON-only stdout) (C13).
|
||||
events_context_envelope = {
|
||||
"*": "suppress",
|
||||
"session_start": "hookSpecificOutput",
|
||||
"user_prompt_submit": "hookSpecificOutput",
|
||||
}
|
||||
# Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
|
||||
# Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
|
||||
# the shared formatter convert the 60s default to 60000ms instead of
|
||||
|
||||
@@ -344,25 +344,6 @@ class PresetManifest:
|
||||
requires = self.data["requires"]
|
||||
if "speckit_version" not in requires:
|
||||
raise PresetValidationError("Missing requires.speckit_version")
|
||||
# Presence alone is not enough: check_compatibility() feeds this value to
|
||||
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
|
||||
# which a non-string escapes two different ways. A float/int/bool/None
|
||||
# raises TypeError from the constructor, while a list or dict is an
|
||||
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
|
||||
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
|
||||
# inside .contains(). Neither is a PresetCompatibilityError, so both
|
||||
# bypass the CLI's "Compatibility Error" handler and exit 1 with a raw
|
||||
# traceback naming no field. An unquoted ``speckit_version: 1.0`` is an
|
||||
# easy YAML slip. Mirrors the sibling IntegrationDescriptor, which already
|
||||
# requires a non-empty string here.
|
||||
if (
|
||||
not isinstance(requires["speckit_version"], str)
|
||||
or not requires["speckit_version"].strip()
|
||||
):
|
||||
raise PresetValidationError(
|
||||
"Invalid requires.speckit_version: expected a non-empty string, "
|
||||
f"got {type(requires['speckit_version']).__name__}"
|
||||
)
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
@@ -550,12 +531,7 @@ class PresetRegistry:
|
||||
if not isinstance(data.get("presets"), dict):
|
||||
data["presets"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError):
|
||||
# Corrupted or missing registry, start fresh. A registry whose
|
||||
# bytes cannot be decoded as UTF-8 is the same corruption class
|
||||
# as malformed JSON — only the exception type differs. OSError is
|
||||
# deliberately not caught: the data may be intact on disk, and
|
||||
# starting fresh would let a later _save() wipe it.
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
return {
|
||||
"schema_version": self.SCHEMA_VERSION,
|
||||
"presets": {}
|
||||
@@ -780,18 +756,6 @@ class PresetManager:
|
||||
PresetCompatibilityError: If pack is incompatible
|
||||
"""
|
||||
required = manifest.requires_speckit_version
|
||||
# Defense in depth: the manifest validator now rejects a non-string
|
||||
# requires.speckit_version, but this method is public and also reachable
|
||||
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
|
||||
# cover a non-string -- scalars raise TypeError from the constructor, and
|
||||
# a list/dict is iterable so it constructs here and only breaks inside
|
||||
# .contains(). Reject up front so this always reports a
|
||||
# PresetCompatibilityError.
|
||||
if not isinstance(required, str):
|
||||
raise PresetCompatibilityError(
|
||||
"Invalid version specifier: expected a string, got "
|
||||
f"{type(required).__name__} ({required!r})"
|
||||
)
|
||||
try:
|
||||
SpecifierSet(required) # Just to validate
|
||||
except InvalidSpecifier:
|
||||
@@ -5576,7 +5540,7 @@ class PresetResolver:
|
||||
if not layers:
|
||||
return None
|
||||
|
||||
def _read_layer_content(layer: Dict[str, Any]) -> Optional[str]:
|
||||
def _read_layer_content(layer: Dict[str, Any]) -> str:
|
||||
"""Read a layer's raw text, rewriting extension-relative subdir
|
||||
references (agents/, knowledge-base/, etc.) to their installed
|
||||
location when the layer is extension-provided (#2101).
|
||||
@@ -5586,18 +5550,8 @@ class PresetResolver:
|
||||
rewrite when it wins outright above or serves as the
|
||||
composition base below — never as a mid-stack composing
|
||||
(append/prepend/wrap) layer.
|
||||
|
||||
Returns None when the layer cannot be read or decoded:
|
||||
collect_all_layers deliberately keeps a non-UTF-8 legacy layer
|
||||
(with its "replace" default) so unrelated commands still
|
||||
resolve, so the same tolerance must apply here — the documented
|
||||
contract is "Composed content string, or None if not found",
|
||||
not a raw UnicodeDecodeError at composition time.
|
||||
"""
|
||||
try:
|
||||
text = layer["path"].read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
text = layer["path"].read_text(encoding="utf-8")
|
||||
extension_id = layer.get("extension_id")
|
||||
extension_dir = layer.get("extension_dir")
|
||||
if extension_id and extension_dir:
|
||||
@@ -5635,8 +5589,6 @@ class PresetResolver:
|
||||
# Convert to reversed_layers index
|
||||
base_reversed_idx = len(layers) - 1 - base_layer_idx
|
||||
content = _read_layer_content(layers[base_layer_idx])
|
||||
if content is None:
|
||||
return None
|
||||
# Compose only the layers above the base (higher priority = lower index in layers,
|
||||
# higher index in reversed_layers). Process bottom-up from base+1.
|
||||
start_idx = base_reversed_idx + 1
|
||||
@@ -5680,12 +5632,7 @@ class PresetResolver:
|
||||
|
||||
# Apply composition layers from bottom to top
|
||||
for layer in reversed_layers[start_idx:]:
|
||||
try:
|
||||
layer_content = layer["path"].read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# Same tolerance as _read_layer_content: an unreadable layer
|
||||
# means the composed result cannot be produced.
|
||||
return None
|
||||
layer_content = layer["path"].read_text(encoding="utf-8")
|
||||
strategy = layer["strategy"]
|
||||
|
||||
if is_command:
|
||||
|
||||
@@ -16,22 +16,6 @@ from .integrations.manifest import IntegrationManifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Managed ``.specify/.gitignore``. Keeps machine-local Spec Kit state out of
|
||||
# version control while leaving shareable project files (specs, constitution,
|
||||
# templates, scripts, extension config) tracked. Patterns are relative to the
|
||||
# ``.specify/`` directory the file lives in.
|
||||
SPECIFY_GITIGNORE_CONTENT = """\
|
||||
# Machine-local Spec Kit state — not meant to be shared.
|
||||
# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh).
|
||||
|
||||
# Local pointer to the current feature directory. Rewritten every time you
|
||||
# switch features, so it is per-checkout state rather than something to share.
|
||||
feature.json
|
||||
|
||||
# Per-machine extension config overrides.
|
||||
extensions/*/local-config.yml
|
||||
"""
|
||||
|
||||
# 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
|
||||
@@ -278,7 +262,8 @@ def _write_shared_bytes(
|
||||
_ensure_safe_shared_destination(project_path, dest)
|
||||
os.replace(temp_path, dest)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
_BASH_FORMAT_COMMAND_RE = re.compile(
|
||||
@@ -623,36 +608,6 @@ def install_shared_infra(
|
||||
)
|
||||
planned_templates.append((dst, rel, content))
|
||||
|
||||
# Managed ``.specify/.gitignore`` — keeps machine-local state (the
|
||||
# ``feature.json`` pointer and per-machine ``local-config.yml`` overrides)
|
||||
# out of git while leaving everything else shareable. Routed through the
|
||||
# same overwrite/skip/preserve policy as templates so ``--force`` refreshes
|
||||
# it and user edits are preserved. Like every other shared-infra file it is
|
||||
# tracked in ``speckit.manifest.json`` (not the per-integration manifest) and
|
||||
# is therefore intentionally left in place by ``integration uninstall``.
|
||||
specify_dir = project_path / ".specify"
|
||||
if _ensure_or_bucket_dir(specify_dir):
|
||||
gitignore_dst = specify_dir / ".gitignore"
|
||||
gitignore_rel = gitignore_dst.relative_to(project_path).as_posix()
|
||||
seen_rels.add(gitignore_rel)
|
||||
if _safe_dest_or_bucket(gitignore_dst, gitignore_rel):
|
||||
write, bucket = _decide_overwrite(gitignore_rel, gitignore_dst)
|
||||
if write:
|
||||
planned_templates.append(
|
||||
(gitignore_dst, gitignore_rel, SPECIFY_GITIGNORE_CONTENT)
|
||||
)
|
||||
elif bucket == "preserved":
|
||||
preserved_user_files.append(gitignore_rel)
|
||||
else:
|
||||
skipped_files.append(gitignore_rel)
|
||||
if gitignore_dst.is_file() and gitignore_rel not in prior_hashes:
|
||||
try:
|
||||
manifest.record_existing(gitignore_rel, recovered=True)
|
||||
except (OSError, ValueError) as exc:
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] could not record {gitignore_rel} in manifest: {exc}"
|
||||
)
|
||||
|
||||
for dst_path, rel, content, mode in planned_copies:
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
|
||||
@@ -1594,12 +1594,6 @@ def workflow_status(
|
||||
except ValueError as exc:
|
||||
err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
except OSError as exc:
|
||||
# An unreadable state.json (bad permissions, a directory in its
|
||||
# place, I/O error) must fail as cleanly as the malformed-JSON
|
||||
# case above -- `workflow resume` already handles OSError here.
|
||||
err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if json_output:
|
||||
# Build on the shared run/resume payload so the common fields
|
||||
|
||||
@@ -761,11 +761,6 @@ class RunState:
|
||||
"Invalid run state: missing required field(s): "
|
||||
+ ", ".join(missing_fields)
|
||||
)
|
||||
if state_data["run_id"] != run_id:
|
||||
raise ValueError(
|
||||
f"Invalid run state: stored run_id {state_data['run_id']!r} "
|
||||
f"does not match requested run_id {run_id!r}"
|
||||
)
|
||||
|
||||
workflow_id = state_data["workflow_id"]
|
||||
if not isinstance(workflow_id, str) or not _ID_PATTERN.fullmatch(
|
||||
@@ -1710,13 +1705,8 @@ class WorkflowEngine:
|
||||
continue
|
||||
state_path = run_dir / "state.json"
|
||||
if state_path.exists():
|
||||
try:
|
||||
with open(state_path, encoding="utf-8") as f:
|
||||
state_data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
||||
continue
|
||||
if not isinstance(state_data, dict) or "run_id" not in state_data:
|
||||
continue
|
||||
with open(state_path, encoding="utf-8") as f:
|
||||
state_data = json.load(f)
|
||||
runs.append(state_data)
|
||||
return runs
|
||||
|
||||
|
||||
@@ -222,6 +222,32 @@ def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
|
||||
assert "old" not in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_blank_markers_use_defaults_matching_bash(tmp_path: Path) -> None:
|
||||
# Regression: with blank markers (config relying on the built-in defaults),
|
||||
# the Bash port must fall back to DEFAULT_START/END, matching the Python and
|
||||
# PowerShell ports. Previously the Bash config-parser transport dropped the
|
||||
# trailing empty marker lines under $(...) command substitution, tripping the
|
||||
# "malformed config parser output" guard so the default-marker substitution
|
||||
# became unreachable and the context file was never updated.
|
||||
markers = {"start": "", "end": ""}
|
||||
repo_a, repo_b = twin_projects(
|
||||
tmp_path, context_file="AGENTS.md", context_markers=markers
|
||||
)
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"<!-- SPECKIT START -->" in content
|
||||
assert b"<!-- SPECKIT END -->" in content
|
||||
assert b"at specs/001-demo/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
|
||||
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
|
||||
@@ -318,19 +344,14 @@ def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mtime_fallback_finds_nested_plan_matching_bash(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The mtime fallback must recurse into scoped layouts.
|
||||
|
||||
A plan created under specs/<scope>/<feature>/plan.md (as produced via
|
||||
SPECIFY_FEATURE_DIRECTORY) is more than one level below specs/. The old
|
||||
Python port used a one-level specs/*/plan.md glob and missed it, while the
|
||||
bash/PowerShell twins recurse (#3024). This locks in the parity.
|
||||
"""
|
||||
def test_python_mtime_fallback_finds_nested_plan_matching_bash(tmp_path: Path) -> None:
|
||||
# Regression: the mtime fallback must discover plan.md in nested scoped
|
||||
# layouts (specs/<scope>/<feature>/plan.md), matching the Bash/PowerShell
|
||||
# ports and the documented recursive-discovery contract (see #3024). A
|
||||
# one-level scan (specs/*/plan.md) would miss this and omit the plan link.
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
for repo in (repo_a, repo_b):
|
||||
plan = repo / "specs" / "backend" / "001-nested" / "plan.md"
|
||||
plan = repo / "specs" / "scope-a" / "002-nested" / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
@@ -340,39 +361,7 @@ def test_python_mtime_fallback_finds_nested_plan_matching_bash(
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/backend/001-nested/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mtime_fallback_skips_plan_reached_through_escaping_symlink(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A plan reached via a specs/ symlink out of the project is not selected.
|
||||
|
||||
``relative_to()`` is lexical, so ``specs/linked/001-x/plan.md`` looks
|
||||
in-project even when ``specs/linked`` points outside the tree. Resolving
|
||||
before the containment check rejects it, so the fallback finds nothing and
|
||||
the ``at <plan>`` line is omitted rather than naming an out-of-project file
|
||||
with an in-project-looking path. Mirrors the bash twin's ``_resolved_rel``.
|
||||
"""
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
for repo in (repo_a, repo_b):
|
||||
outside = repo.parent / f"outside-{repo.name}" / "001-x"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
(outside / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
specs = repo / "specs"
|
||||
specs.mkdir(parents=True, exist_ok=True)
|
||||
(specs / "linked").symlink_to(outside.parent, target_is_directory=True)
|
||||
# Sanity: the plan really is reachable through the symlink.
|
||||
assert (specs / "linked" / "001-x" / "plan.md").is_file()
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"\nat " not in content
|
||||
assert b"at specs/scope-a/002-nested/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
@@ -519,31 +508,6 @@ def test_python_fresh_context_file_matches_powershell(tmp_path: Path) -> None:
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_mtime_fallback_finds_nested_plan_matches_powershell(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Python's mtime fallback must recurse like the PowerShell twin.
|
||||
|
||||
With no feature.json, discovery falls back to scanning under specs/. A plan
|
||||
at specs/<scope>/<feature>/plan.md sits more than one level deep; the old
|
||||
Python one-level glob missed it while PowerShell already recurses (#3024).
|
||||
"""
|
||||
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
|
||||
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
|
||||
for repo in (repo_a, repo_b):
|
||||
plan = repo / "specs" / "backend" / "001-nested" / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
ps = run_powershell(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/backend/001-nested/plan.md" in (repo_b / "AGENTS.md").read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_upsert_matches_powershell(tmp_path: Path) -> None:
|
||||
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
|
||||
|
||||
@@ -186,50 +186,6 @@ def test_local_zip_uses_bounded_archive_open(tmp_path: Path):
|
||||
_local_manifest_source(str(artifact))
|
||||
|
||||
|
||||
def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path):
|
||||
"""A malformed bundle.yml inside a .zip must raise BundlerError.
|
||||
|
||||
The zip branch parses YAML inline rather than through load_yaml(), so the
|
||||
raw yaml.YAMLError used to escape. It is neither a ValueError nor an
|
||||
OSError, so nothing upstream caught it.
|
||||
"""
|
||||
artifact = tmp_path / "bad-manifest.zip"
|
||||
with zipfile.ZipFile(artifact, "w") as archive:
|
||||
archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n")
|
||||
|
||||
with pytest.raises(BundlerError, match="Invalid YAML"):
|
||||
_local_manifest_source(str(artifact))
|
||||
|
||||
|
||||
def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Path):
|
||||
"""`bundle install` reports malformed YAML the same way for all 3 sources.
|
||||
|
||||
Directory and bundle.yml sources already exited 1 with an "Invalid YAML"
|
||||
message; the .zip source dumped a yaml.parser.ParserError traceback.
|
||||
"""
|
||||
bad_yaml = "bundle: [unclosed\n id: demo\n"
|
||||
|
||||
directory = tmp_path / "dir-src"
|
||||
directory.mkdir()
|
||||
(directory / "bundle.yml").write_text(bad_yaml, encoding="utf-8")
|
||||
|
||||
manifest_file = tmp_path / "standalone.yml"
|
||||
manifest_file.write_text(bad_yaml, encoding="utf-8")
|
||||
|
||||
artifact = tmp_path / "artifact.zip"
|
||||
with zipfile.ZipFile(artifact, "w") as archive:
|
||||
archive.writestr("bundle.yml", bad_yaml)
|
||||
|
||||
runner = CliRunner()
|
||||
for source in (directory, manifest_file, artifact):
|
||||
result = runner.invoke(app, ["bundle", "install", str(source)])
|
||||
assert result.exit_code == 1, f"{source.name}: {result.output}"
|
||||
assert result.exception is None or isinstance(
|
||||
result.exception, SystemExit
|
||||
), f"{source.name} leaked {type(result.exception).__name__}"
|
||||
assert "Invalid YAML" in result.output, f"{source.name}: {result.output}"
|
||||
|
||||
|
||||
def test_invalid_local_manifest_is_rejected_before_project_init(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
|
||||
+16
-118
@@ -69,11 +69,8 @@ class TestInitIntegrationFlag:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
assert (
|
||||
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
).exists()
|
||||
assert not (project / ".github" / "agents").exists()
|
||||
assert not (project / ".github" / "prompts").exists()
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists()
|
||||
assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists()
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
@@ -81,7 +78,6 @@ class TestInitIntegrationFlag:
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts["integration"] == "copilot"
|
||||
assert opts["ai_skills"] is True
|
||||
# init must not leave any legacy agent-context keys in init-options.json
|
||||
assert "context_file" not in opts
|
||||
|
||||
@@ -115,9 +111,7 @@ class TestInitIntegrationFlag:
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert f"defaulting to '{specify_cli.DEFAULT_INIT_INTEGRATION}'" in result.output
|
||||
assert (
|
||||
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
).exists()
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
||||
@@ -256,9 +250,7 @@ class TestInitIntegrationFlag:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
).exists()
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
|
||||
def test_init_optional_preset_failure_reports_target_and_continues(
|
||||
self, tmp_path, monkeypatch
|
||||
@@ -1067,99 +1059,6 @@ class TestInitIntegrationFlag:
|
||||
assert "not updated" in result.output
|
||||
|
||||
|
||||
def test_init_here_force_reapplies_installed_presets(self, tmp_path, monkeypatch):
|
||||
"""Regression for #3990: init --here --force must call _register_presets_for_agent
|
||||
after setup() so preset-composed files are not silently reverted to core."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "force-preset-reapply"
|
||||
project.mkdir()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
|
||||
# First init to create a valid project structure.
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--force",
|
||||
"--integration", "claude",
|
||||
"--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Second init --here --force: verify _register_presets_for_agent is called.
|
||||
# Patch at the source module since init.py does a lazy import of these functions.
|
||||
mock_presets = MagicMock()
|
||||
mock_extensions = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"specify_cli.integrations._helpers._register_presets_for_agent",
|
||||
mock_presets,
|
||||
),
|
||||
patch(
|
||||
"specify_cli.integrations._helpers._register_extensions_for_agent",
|
||||
mock_extensions,
|
||||
),
|
||||
):
|
||||
result2 = runner.invoke(app, [
|
||||
"init", "--here", "--force",
|
||||
"--integration", "claude",
|
||||
"--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result2.exit_code == 0, result2.output
|
||||
assert mock_presets.called, (
|
||||
"_register_presets_for_agent was not called during init --here --force"
|
||||
)
|
||||
assert mock_extensions.called, (
|
||||
"_register_extensions_for_agent was not called during init --here --force"
|
||||
)
|
||||
|
||||
def test_init_here_without_force_does_not_reapply_presets(self, tmp_path):
|
||||
"""Without --force (fresh project), _register_presets_for_agent should NOT be called."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "no-force-preset"
|
||||
project.mkdir()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
mock_presets = MagicMock()
|
||||
with patch(
|
||||
"specify_cli.integrations._helpers._register_presets_for_agent",
|
||||
mock_presets,
|
||||
):
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here",
|
||||
"--integration", "claude",
|
||||
"--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# On a fresh project without --force the reapply guard should not fire.
|
||||
assert not mock_presets.called, (
|
||||
"_register_presets_for_agent should not be called on a fresh init without --force"
|
||||
)
|
||||
|
||||
|
||||
class TestForceExistingDirectory:
|
||||
"""Tests for --force merging into an existing named directory."""
|
||||
|
||||
@@ -1474,7 +1373,7 @@ class TestSharedInfraCommandRefs:
|
||||
assert "/speckit.specify" not in script_content
|
||||
|
||||
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
|
||||
"""Default Copilot skills mode produces hyphen refs in page templates."""
|
||||
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -1496,28 +1395,27 @@ class TestSharedInfraCommandRefs:
|
||||
|
||||
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||
content = plan.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in content, "Copilot skills should use /speckit-plan"
|
||||
assert "/speckit.plan" not in content
|
||||
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
|
||||
script_content = self._combined_script_content(project, "sh")
|
||||
assert "/speckit-specify" in script_content
|
||||
assert "/speckit.specify" not in script_content
|
||||
assert "/speckit.specify" in script_content
|
||||
assert "/speckit-specify" not in script_content
|
||||
|
||||
def test_full_init_copilot_commands_resolves_page_templates(self, tmp_path):
|
||||
"""Copilot --commands produces dot refs in page templates."""
|
||||
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
|
||||
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
project = tmp_path / "init-copilot-commands"
|
||||
project = tmp_path / "init-copilot-skills"
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"init", str(project),
|
||||
"--integration", "copilot",
|
||||
"--integration-options", "--commands",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
@@ -1528,13 +1426,13 @@ class TestSharedInfraCommandRefs:
|
||||
|
||||
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||
content = plan.read_text(encoding="utf-8")
|
||||
assert "/speckit.plan" in content, "Copilot --commands should use /speckit.plan"
|
||||
assert "/speckit-plan" not in content
|
||||
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
|
||||
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
|
||||
script_content = self._combined_script_content(project, "sh")
|
||||
assert "/speckit.specify" in script_content
|
||||
assert "/speckit-specify" not in script_content
|
||||
assert "/speckit-specify" in script_content
|
||||
assert "/speckit.specify" not in script_content
|
||||
|
||||
|
||||
class TestIntegrationCatalogDiscoveryCLI:
|
||||
|
||||
@@ -128,23 +128,6 @@ class TestResolveEvents:
|
||||
)
|
||||
assert result == {}
|
||||
|
||||
def test_unreadable_yaml_override_keeps_prior_layers(self, tmp_path):
|
||||
"""An unreadable override is ignored like malformed YAML."""
|
||||
override_file = tmp_path / ".specify" / "integration-events.yml"
|
||||
override_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
override_file.write_bytes(b"\xff\xfe")
|
||||
|
||||
result = resolve_events(
|
||||
"claude",
|
||||
{"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
|
||||
tmp_path,
|
||||
None,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"post_tool_use": [{"command": "speckit.tdd.validate"}]
|
||||
}
|
||||
|
||||
def test_no_config_no_events(self, tmp_path):
|
||||
"""Safe fallback with empty config/options."""
|
||||
result = resolve_events("claude", None, tmp_path, None)
|
||||
@@ -251,8 +234,6 @@ class TestCanonicalEventMapping:
|
||||
integration = OpencodeIntegration()
|
||||
assert integration.supports_events() is True
|
||||
assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "tool.execute.before"
|
||||
assert integration.CANONICAL_TO_NATIVE["session_start"] == "experimental.chat.system.transform"
|
||||
assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "chat.message"
|
||||
assert "stop" not in integration.CANONICAL_TO_NATIVE
|
||||
|
||||
def test_copilot_mapping(self):
|
||||
@@ -673,104 +654,6 @@ class TestCopilotAgentStop:
|
||||
|
||||
# -- Shell quoting & matcher escaping (R2, R4) -------------------------------
|
||||
|
||||
class TestContextInjectionEnvelopes:
|
||||
"""C13: context-injection envelope resolution and emission."""
|
||||
|
||||
def test_emit_event_stdout_wrapping(self, capsys):
|
||||
from specify_cli.events import _emit_event_stdout
|
||||
|
||||
_emit_event_stdout("hello ctx", "plain")
|
||||
assert capsys.readouterr().out == "hello ctx"
|
||||
|
||||
# hookSpecificOutput without native_event: no hookEventName (backward
|
||||
# compat for callers that don't pass it).
|
||||
_emit_event_stdout("hello ctx", "hookSpecificOutput")
|
||||
assert json.loads(capsys.readouterr().out.strip()) == {
|
||||
"hookSpecificOutput": {"additionalContext": "hello ctx"}
|
||||
}
|
||||
|
||||
# hookSpecificOutput with native_event: hookEventName included
|
||||
# (required by Qwen's hooks spec; derived from Claude Code's).
|
||||
_emit_event_stdout("hello ctx", "hookSpecificOutput", "SessionStart")
|
||||
assert json.loads(capsys.readouterr().out.strip()) == {
|
||||
"hookSpecificOutput": {
|
||||
"additionalContext": "hello ctx",
|
||||
"hookEventName": "SessionStart",
|
||||
}
|
||||
}
|
||||
|
||||
_emit_event_stdout("hello ctx", "additionalContext")
|
||||
assert json.loads(capsys.readouterr().out.strip()) == {
|
||||
"additionalContext": "hello ctx"
|
||||
}
|
||||
|
||||
_emit_event_stdout("hello ctx", "additional_context")
|
||||
assert json.loads(capsys.readouterr().out.strip()) == {
|
||||
"additional_context": "hello ctx"
|
||||
}
|
||||
|
||||
_emit_event_stdout("hello ctx", "suppress")
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
# Empty output emits nothing under any envelope.
|
||||
_emit_event_stdout("", "additionalContext")
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
def test_envelope_resolution_and_command_formatting(self):
|
||||
from specify_cli.events import _dispatcher_command, _context_envelope_for
|
||||
from specify_cli.integrations.gemini import GeminiIntegration
|
||||
from specify_cli.integrations.qwen import QwenIntegration
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
from specify_cli.integrations.cursor_agent import CursorAgentIntegration
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
gemini = GeminiIntegration()
|
||||
assert _context_envelope_for(gemini, "session_start") == "hookSpecificOutput"
|
||||
assert _context_envelope_for(gemini, "user_prompt_submit") == "hookSpecificOutput"
|
||||
assert _context_envelope_for(gemini, "pre_tool_use") == "suppress"
|
||||
|
||||
# hookSpecificOutput appends the native event name as a 6th dispatcher
|
||||
# argument so the dispatcher can populate hookEventName. The default
|
||||
# timeout (60s) is always emitted as the 4th arg to keep positional
|
||||
# alignment (R3).
|
||||
cmd_gemini_start = _dispatcher_command(gemini, Path("/proj"), "speckit.boot", "session_start")
|
||||
assert cmd_gemini_start.endswith(" 60 hookSpecificOutput SessionStart")
|
||||
|
||||
cmd_gemini_prompt = _dispatcher_command(gemini, Path("/proj"), "speckit.prompt", "user_prompt_submit")
|
||||
assert cmd_gemini_prompt.endswith(" 60 hookSpecificOutput BeforeAgent")
|
||||
|
||||
cmd_gemini_tool = _dispatcher_command(gemini, Path("/proj"), "speckit.guard", "pre_tool_use")
|
||||
assert cmd_gemini_tool.endswith(" 60 suppress")
|
||||
|
||||
# Qwen uses the same hookSpecificOutput protocol with its own native
|
||||
# event names; verify hookEventName threading for Qwen's CamelCase names.
|
||||
qwen = QwenIntegration()
|
||||
cmd_qwen_start = _dispatcher_command(qwen, Path("/proj"), "speckit.boot", "session_start")
|
||||
assert cmd_qwen_start.endswith(" 60 hookSpecificOutput SessionStart")
|
||||
cmd_qwen_prompt = _dispatcher_command(qwen, Path("/proj"), "speckit.prompt", "user_prompt_submit")
|
||||
assert cmd_qwen_prompt.endswith(" 60 hookSpecificOutput UserPromptSubmit")
|
||||
|
||||
copilot = CopilotIntegration()
|
||||
assert _context_envelope_for(copilot, "session_start") == "additionalContext"
|
||||
assert _context_envelope_for(copilot, "user_prompt_submit") == "additionalContext"
|
||||
cmd_copilot_start = _dispatcher_command(copilot, Path("/proj"), "speckit.boot", "session_start")
|
||||
assert cmd_copilot_start.endswith(" 60 additionalContext")
|
||||
cmd_copilot_prompt = _dispatcher_command(copilot, Path("/proj"), "speckit.prompt", "user_prompt_submit")
|
||||
assert cmd_copilot_prompt.endswith(" 60 additionalContext")
|
||||
|
||||
cursor = CursorAgentIntegration()
|
||||
assert _context_envelope_for(cursor, "session_start") == "additional_context"
|
||||
assert _context_envelope_for(cursor, "user_prompt_submit") == "suppress"
|
||||
cmd_cursor_start = _dispatcher_command(cursor, Path("/proj"), "speckit.boot", "session_start")
|
||||
assert cmd_cursor_start.endswith(" 60 additional_context")
|
||||
|
||||
claude = ClaudeIntegration()
|
||||
codex = CodexIntegration()
|
||||
assert _context_envelope_for(claude, "session_start") is None
|
||||
assert _context_envelope_for(codex, "session_start") is None
|
||||
|
||||
|
||||
class TestDispatcherCommandQuoting:
|
||||
"""R2: dispatcher command components are shell-quoted so spaces and shell
|
||||
metacharacters are passed as single arguments, not reinterpreted."""
|
||||
@@ -868,59 +751,6 @@ class TestTomlMatcherEscaping:
|
||||
assert group["matcher"] == 'Ba"sh'
|
||||
|
||||
|
||||
class TestTomlUnreadableConfig:
|
||||
"""An undecodable user config.toml must not crash install or teardown.
|
||||
|
||||
Every JSON merge/remove path goes through ``_load_user_json``, which
|
||||
skips on an unreadable or malformed file to preserve user content (#22).
|
||||
The TOML merge and remove read the user's config.toml with no boundary,
|
||||
so a non-UTF-8 (or otherwise unreadable) file crashed
|
||||
``install_integration_events``/``remove_integration_events`` with a raw
|
||||
``UnicodeDecodeError`` — and the merge path would have regenerated the
|
||||
file, discarding the user's bytes, had it not crashed first.
|
||||
"""
|
||||
|
||||
def test_merge_skips_unreadable_config_and_preserves_bytes(self, tmp_path):
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
integration = CodexIntegration()
|
||||
manifest = _claude_manifest(tmp_path)
|
||||
config_path = tmp_path / ".codex" / "config.toml"
|
||||
config_path.parent.mkdir(parents=True)
|
||||
user_bytes = b"# codex config \xff\xfe not utf-8\n"
|
||||
config_path.write_bytes(user_bytes)
|
||||
|
||||
install_integration_events(
|
||||
integration, tmp_path, manifest,
|
||||
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
||||
)
|
||||
|
||||
# User bytes preserved and the skipped file is not tracked (S5).
|
||||
assert config_path.read_bytes() == user_bytes
|
||||
manifest.record_existing.assert_not_called()
|
||||
|
||||
def test_teardown_skips_unreadable_config_and_preserves_bytes(self, tmp_path):
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
integration = CodexIntegration()
|
||||
manifest = _claude_manifest(tmp_path)
|
||||
install_integration_events(
|
||||
integration, tmp_path, manifest,
|
||||
{"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
|
||||
)
|
||||
config_path = tmp_path / ".codex" / "config.toml"
|
||||
assert config_path.is_file()
|
||||
|
||||
# The user (or another tool) rewrites the config as non-UTF-8
|
||||
# between install and uninstall.
|
||||
user_bytes = b"# rewritten \xff\xfe not utf-8\n"
|
||||
config_path.write_bytes(user_bytes)
|
||||
|
||||
remove_integration_events(integration, tmp_path, manifest)
|
||||
|
||||
assert config_path.read_bytes() == user_bytes
|
||||
|
||||
|
||||
# -- Opencode TS Plugin merging ---------------------------------------------
|
||||
|
||||
class TestOpencodePluginMerging:
|
||||
@@ -936,7 +766,6 @@ class TestOpencodePluginMerging:
|
||||
events = {
|
||||
"pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}],
|
||||
"session_start": [{"command": "speckit.agent-context.update"}],
|
||||
"session_end": [{"command": "speckit.agent-context.teardown"}],
|
||||
}
|
||||
install_integration_events(integration, tmp_path, manifest, events)
|
||||
|
||||
@@ -945,50 +774,13 @@ class TestOpencodePluginMerging:
|
||||
content = plugin_path.read_text()
|
||||
assert "runEvent" in content
|
||||
assert "tool.execute.before" in content
|
||||
assert "experimental.chat.system.transform" in content
|
||||
assert "session.created" in content
|
||||
assert "speckit.tdd.validate" in content
|
||||
assert "speckit.agent-context.update" in content
|
||||
# #13: failures must propagate via throw, not process.exit(2) which
|
||||
# would kill the OpenCode host process.
|
||||
assert "process.exit(2)" not in content
|
||||
assert "throw new Error" in content
|
||||
# session_start (experimental.chat.system.transform) must be guarded
|
||||
# so canonical session-start handlers only run when a session is
|
||||
# present — OpenCode fires this hook for non-session operations
|
||||
# (e.g. agent generation) with no sessionID.
|
||||
assert "if (!input.sessionID) return;" in content
|
||||
# session_start handler output is cached per sessionID so non-idempotent
|
||||
# handlers run once per session instead of on every LLM request.
|
||||
assert "sessionStartCache" in content
|
||||
assert "sessionStartCache.get(input.sessionID)" in content
|
||||
assert "sessionStartCache.set(input.sessionID" in content
|
||||
# Cache is evicted on session.deleted (session_end).
|
||||
assert "sessionStartCache.delete(event.sessionID)" in content
|
||||
|
||||
def test_opencode_ts_plugin_chat_message_part_injection(self, tmp_path):
|
||||
"""user_prompt_submit emits chat.message pushing a synthetic TextPart.
|
||||
The part ID derives from output.parts[last].id (prt_ brand preserved)
|
||||
with a prt_ fallback to prevent OpenCode session schema crashes."""
|
||||
integration = OpencodeIntegration()
|
||||
manifest = MagicMock(spec=IntegrationManifest)
|
||||
manifest.files = {}
|
||||
manifest.record_file = MagicMock()
|
||||
manifest.record_existing = MagicMock()
|
||||
|
||||
events = {
|
||||
"user_prompt_submit": [{"command": "speckit.discover"}],
|
||||
}
|
||||
install_integration_events(integration, tmp_path, manifest, events)
|
||||
|
||||
plugin_path = tmp_path / ".opencode/plugin/speckit-events.ts"
|
||||
assert plugin_path.is_file()
|
||||
content = plugin_path.read_text()
|
||||
assert "chat.message" in content
|
||||
assert "output.parts.push" in content
|
||||
assert "synthetic: true" in content
|
||||
assert 'type: "text"' in content
|
||||
assert "output.parts[output.parts.length - 1]?.id" in content
|
||||
assert '?? "prt_"' in content
|
||||
|
||||
def test_opencode_ts_plugin_resolves_interpreter_and_directory_at_load(self, tmp_path):
|
||||
"""C8/C9: the dispatcher + interpreter are resolved per-project at
|
||||
@@ -1239,79 +1031,6 @@ class TestCommandRunner:
|
||||
assert PurePath(argv[1]).as_posix().endswith(".specify/scripts/python/boot.py")
|
||||
assert ".specify" in argv[1]
|
||||
|
||||
def test_unparseable_script_command_returns_none(self, tmp_path):
|
||||
"""A ``scripts:`` value shlex cannot tokenize must resolve to no argv.
|
||||
|
||||
The generated dispatcher's ``_resolve_argv`` twin wraps its
|
||||
``shlex.split`` in ``except ValueError: return None``, but the
|
||||
CLI-side resolver did not: an unclosed quote in a ``scripts:``
|
||||
frontmatter value raised a raw ``ValueError: No closing quotation``
|
||||
through ``resolve_and_run_event_command`` instead of degrading to
|
||||
"no runnable script" like every other malformed-input case here.
|
||||
"""
|
||||
from specify_cli.events import _resolve_event_command_argv
|
||||
|
||||
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
(cmd_dir / "boot.md").write_text(
|
||||
"---\n"
|
||||
"description: \"Boot\"\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/boot.sh \"unclosed\n"
|
||||
"---\nBody\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
|
||||
|
||||
assert argv is None
|
||||
|
||||
def test_unreadable_template_returns_none(self, tmp_path):
|
||||
"""A command template that cannot be read must resolve to no argv.
|
||||
|
||||
Every other failure inside ``_resolve_event_command_argv`` — missing
|
||||
frontmatter, malformed YAML, absent scripts — degrades to ``None`` so
|
||||
the dispatcher treats the command as declaring no runnable script.
|
||||
The initial ``read_text`` was the one step outside that boundary: a
|
||||
non-UTF-8 template raised a raw ``UnicodeDecodeError`` through
|
||||
``resolve_and_run_event_command`` and out of ``specify event run``.
|
||||
"""
|
||||
from specify_cli.events import _resolve_event_command_argv
|
||||
|
||||
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
(cmd_dir / "boot.md").write_bytes(
|
||||
b"---\ndescription: \"B\xff\xfeoot\"\n---\nBody\n"
|
||||
)
|
||||
|
||||
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
|
||||
assert argv is None
|
||||
|
||||
def test_permission_denied_template_returns_none(self, tmp_path, monkeypatch):
|
||||
"""The same boundary must cover ``OSError`` (e.g. permission denied).
|
||||
|
||||
Mocked rather than chmod-based so the case also holds under
|
||||
privileged CI where permission bits are not enforced.
|
||||
"""
|
||||
from specify_cli.events import _resolve_event_command_argv
|
||||
|
||||
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
template = cmd_dir / "boot.md"
|
||||
template.write_text("---\ndescription: Boot\n---\nBody\n")
|
||||
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def failing_read_text(self_path, *args, **kwargs):
|
||||
if self_path == template:
|
||||
raise PermissionError(13, "Permission denied")
|
||||
return original_read_text(self_path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", failing_read_text)
|
||||
|
||||
argv = _resolve_event_command_argv(template, tmp_path, None)
|
||||
assert argv is None
|
||||
|
||||
def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path):
|
||||
"""S6: the ps variant prefixes argv with pwsh/powershell -File so
|
||||
subprocess.run(shell=False) can execute the .ps1 script."""
|
||||
@@ -1390,7 +1109,7 @@ class TestCommandRunner:
|
||||
content = (tmp_path / EVENTS_DISPATCHER_REL).read_text()
|
||||
# Delegates to specify_cli when importable.
|
||||
assert "from specify_cli.events import resolve_and_run_event_command" in content
|
||||
assert "except (ImportError, TypeError):" in content
|
||||
assert "except ImportError" in content
|
||||
# Inline stdlib fallback resolver for one-time/temporary installs.
|
||||
assert "_run_inline" in content
|
||||
assert "_find_command_template" in content
|
||||
|
||||
@@ -426,7 +426,7 @@ class _RunCapture:
|
||||
return _Result()
|
||||
|
||||
|
||||
def test_copilot_commands_dispatch_includes_extra_args(monkeypatch):
|
||||
def test_copilot_dispatch_command_includes_extra_args(monkeypatch):
|
||||
"""Locks the bypass fix: `CopilotIntegration.dispatch_command`
|
||||
must honour `SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS`, not just `build_exec_args`.
|
||||
"""
|
||||
@@ -441,9 +441,9 @@ def test_copilot_commands_dispatch_includes_extra_args(monkeypatch):
|
||||
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
|
||||
)
|
||||
|
||||
integration = CopilotIntegration()
|
||||
integration._skills_mode = False
|
||||
integration.dispatch_command("speckit.plan", args="body", stream=False)
|
||||
CopilotIntegration().dispatch_command(
|
||||
"speckit.plan", args="body", stream=False
|
||||
)
|
||||
|
||||
assert capture.captured_args is not None
|
||||
# Hook inserted between `-p prompt` and the canonical Copilot flags.
|
||||
|
||||
@@ -238,7 +238,6 @@ class MarkdownIntegrationTests:
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
files.append(".specify/.gitignore")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
|
||||
|
||||
@@ -141,91 +141,6 @@ class SkillsIntegrationTests:
|
||||
assert isinstance(fm["description"], str)
|
||||
assert len(fm["description"]) > 0, f"{f} has empty description"
|
||||
|
||||
def test_skill_frontmatter_preserves_multiline_description(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""A multiline (block-scalar) description must round-trip exactly.
|
||||
|
||||
The hand-built SKILL.md frontmatter used to only escape backslash and
|
||||
quote, so a block-scalar description was emitted with raw newlines inside
|
||||
a double-quoted scalar and reparsed with those newlines collapsed to
|
||||
spaces. The description must survive byte-for-byte."""
|
||||
from pathlib import Path
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
# Hermes writes to ~/.hermes/skills/ — isolate Path.home() to prevent
|
||||
# overwriting a developer's real global skill directory.
|
||||
if self.KEY == "hermes":
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"description: |\n"
|
||||
" first line\n"
|
||||
" second line\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/x.sh\n"
|
||||
"---\n"
|
||||
"Body\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) == 1
|
||||
|
||||
content = skill_files[0].read_text(encoding="utf-8")
|
||||
fm = yaml.safe_load(content.split("---", 2)[1])
|
||||
assert "\n" in fm["description"]
|
||||
assert fm["description"] == "first line\nsecond line\n"
|
||||
|
||||
def test_skill_frontmatter_preserves_control_characters(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""A description carrying a C0/DEL control char must round-trip exactly.
|
||||
|
||||
A control character can reach ``description`` via a YAML escape in the
|
||||
source template (``"a\\x08b"`` parses to a real U+0008). The old
|
||||
hand-built frontmatter only escaped backslash and quote, so the raw
|
||||
control char landed inside the emitted double-quoted scalar and made the
|
||||
SKILL.md unparseable / lossy. ``yaml_quote`` must escape it so the
|
||||
value survives byte-for-byte."""
|
||||
from pathlib import Path
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
# Hermes writes to ~/.hermes/skills/ — isolate Path.home() to prevent
|
||||
# overwriting a developer's real global skill directory.
|
||||
if self.KEY == "hermes":
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
'description: "a\\x08b\\ttab"\n'
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/x.sh\n"
|
||||
"---\n"
|
||||
"Body\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) == 1
|
||||
|
||||
content = skill_files[0].read_text(encoding="utf-8")
|
||||
fm = yaml.safe_load(content.split("---", 2)[1])
|
||||
assert fm["description"] == "a\x08b\ttab"
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
"""Skill body must have placeholders replaced, not raw templates."""
|
||||
i = get_integration(self.KEY)
|
||||
@@ -484,7 +399,6 @@ class SkillsIntegrationTests:
|
||||
".specify/integration.json",
|
||||
f".specify/integrations/{self.KEY}.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/.gitignore",
|
||||
".specify/memory/.constitution-template.json",
|
||||
".specify/memory/constitution.md",
|
||||
]
|
||||
|
||||
@@ -488,7 +488,6 @@ class TomlIntegrationTests:
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
files.append(".specify/.gitignore")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in [
|
||||
|
||||
@@ -402,7 +402,6 @@ class YamlIntegrationTests:
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
files.append(".specify/.gitignore")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in [
|
||||
|
||||
@@ -223,6 +223,33 @@ class TestActiveCatalogs:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _OversizedResponse:
|
||||
"""Response stub that supports bounded streaming reads for oversized-catalog tests."""
|
||||
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
self._pos = 0
|
||||
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos : self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class TestCatalogFetch:
|
||||
"""Tests that use a local HTTP server stub via monkeypatch."""
|
||||
|
||||
@@ -233,15 +260,15 @@ class TestCatalogFetch:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
self._offset = 0
|
||||
self._pos = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size == -1:
|
||||
chunk = self._data[self._offset:]
|
||||
self._offset = len(self._data)
|
||||
else:
|
||||
chunk = self._data[self._offset:self._offset + size]
|
||||
self._offset += len(chunk)
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
@@ -330,187 +357,6 @@ class TestCatalogFetch:
|
||||
results = cat.search()
|
||||
assert "acme-coder" in [r["id"] for r in results]
|
||||
|
||||
def test_fetch_rejects_oversized_catalog_response(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Regression: _fetch_single_catalog must use read_response_limited
|
||||
with MAX_JSON_METADATA_BYTES, not unbounded resp.read()."""
|
||||
from specify_cli.integrations.catalog import (
|
||||
IntegrationCatalog,
|
||||
IntegrationCatalogError,
|
||||
)
|
||||
import specify_cli.integrations.catalog as catalog_module
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
# Set limit very small so any response is oversized
|
||||
monkeypatch.setattr(catalog_module, "MAX_JSON_METADATA_BYTES", 32)
|
||||
|
||||
class _OversizedResponse:
|
||||
def __init__(self):
|
||||
self._data = b"x" * 64
|
||||
self._offset = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size == -1:
|
||||
chunk = self._data[self._offset:]
|
||||
self._offset = len(self._data)
|
||||
else:
|
||||
chunk = self._data[self._offset:self._offset + size]
|
||||
self._offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return "https://example.com/catalog.json"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
def fake_urlopen(req, timeout=10):
|
||||
return _OversizedResponse()
|
||||
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
from specify_cli.integrations.catalog import IntegrationCatalogEntry
|
||||
|
||||
entry = IntegrationCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrationCatalogError, match="exceeds maximum size"):
|
||||
cat._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def _patch_urlopen_bytes(self, monkeypatch, bodies):
|
||||
"""Patch urlopen to serve raw *bodies* keyed by URL substring.
|
||||
|
||||
Mirrors ``_patch_urlopen`` but passes the bytes through verbatim: these
|
||||
tests need a body that is not valid UTF-8, which ``json.dumps`` cannot
|
||||
produce.
|
||||
"""
|
||||
|
||||
class _RawResponse:
|
||||
def __init__(self, data, url):
|
||||
self._data = data
|
||||
self._url = url
|
||||
self._offset = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size == -1:
|
||||
chunk = self._data[self._offset:]
|
||||
self._offset = len(self._data)
|
||||
else:
|
||||
chunk = self._data[self._offset:self._offset + size]
|
||||
self._offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
def fake_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
for marker, body in bodies.items():
|
||||
if marker in url:
|
||||
return _RawResponse(body, url)
|
||||
raise AssertionError(f"unexpected URL requested: {url}")
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
def test_fetch_wraps_non_utf8_catalog_response(self, tmp_path, monkeypatch):
|
||||
"""Regression: a non-UTF-8 response body must raise IntegrationCatalogError.
|
||||
|
||||
``.decode("utf-8")`` runs before ``json.loads``, so the resulting
|
||||
UnicodeDecodeError is not a JSONDecodeError and slipped past both
|
||||
handlers as a raw traceback.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir(exist_ok=True)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
self._patch_urlopen_bytes(
|
||||
monkeypatch,
|
||||
{"catalog.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}'},
|
||||
)
|
||||
|
||||
entry = IntegrationCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrationCatalogError, match="not valid UTF-8"):
|
||||
cat._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def test_search_skips_non_utf8_catalog(self, tmp_path, monkeypatch, capsys):
|
||||
"""A single non-UTF-8 catalog must not take down the whole search.
|
||||
|
||||
``_get_merged_integrations`` is built to warn and continue on a bad
|
||||
catalog; an unwrapped UnicodeDecodeError defeated that entirely.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir(exist_ok=True)
|
||||
(specify / "integration-catalogs.yml").write_text(
|
||||
"catalogs:\n"
|
||||
" - name: broken\n"
|
||||
" url: https://example.com/broken.json\n"
|
||||
" priority: 1\n"
|
||||
" - name: healthy\n"
|
||||
" url: https://example.com/healthy.json\n"
|
||||
" priority: 2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
healthy = json.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"integrations": {
|
||||
"acme-coder": {
|
||||
"name": "Acme Coder",
|
||||
"version": "1.0.0",
|
||||
"description": "Acme integration",
|
||||
}
|
||||
},
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
self._patch_urlopen_bytes(
|
||||
monkeypatch,
|
||||
{
|
||||
"broken.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}',
|
||||
"healthy.json": healthy,
|
||||
},
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
results = cat.search()
|
||||
|
||||
assert "acme-coder" in [r["id"] for r in results]
|
||||
assert "broken" in capsys.readouterr().err
|
||||
|
||||
def test_search_by_tag(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
@@ -586,6 +432,90 @@ class TestCatalogFetch:
|
||||
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
|
||||
cat.search()
|
||||
|
||||
def test_oversized_catalog_response_rejected(self, tmp_path, monkeypatch):
|
||||
"""Response exceeding MAX_JSON_METADATA_BYTES is caught as IntegrationCatalogError.
|
||||
|
||||
The per-entry error is logged as a warning and skipped (not fatal).
|
||||
When ALL catalogs are oversized, search() raises the aggregate error.
|
||||
"""
|
||||
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
# Build a valid catalog dict whose JSON encoding exceeds the limit.
|
||||
oversized = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {},
|
||||
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
|
||||
}
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
def _oversized_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
return _OversizedResponse(oversized, url)
|
||||
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _oversized_urlopen)
|
||||
|
||||
# Both default + community catalogs are oversized → all fail → aggregate error.
|
||||
# The per-entry IntegrationCatalogError (with "exceeds maximum size") is
|
||||
# logged as a warning; the aggregate raise has a different message.
|
||||
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
|
||||
cat.search()
|
||||
|
||||
def test_oversized_catalog_does_not_block_healthy_one(self, tmp_path, monkeypatch):
|
||||
"""When one catalog is oversized, the healthy catalog still returns results."""
|
||||
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir()
|
||||
|
||||
healthy_catalog = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {
|
||||
"good-agent": {
|
||||
"id": "good-agent",
|
||||
"name": "Good Agent",
|
||||
"version": "1.0.0",
|
||||
"description": "A healthy integration",
|
||||
"author": "test-org",
|
||||
},
|
||||
},
|
||||
}
|
||||
oversized_catalog = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {},
|
||||
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
|
||||
}
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text(yaml.dump({"catalogs": [
|
||||
{"url": "https://healthy.example.com/catalog.json", "name": "healthy", "priority": 1, "install_allowed": True},
|
||||
{"url": "https://oversized.example.com/catalog.json", "name": "oversized", "priority": 2, "install_allowed": True},
|
||||
]}))
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
def _multi_catalog_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
if "oversized" in url:
|
||||
return _OversizedResponse(oversized_catalog, url)
|
||||
return _OversizedResponse(healthy_catalog, url)
|
||||
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _multi_catalog_urlopen)
|
||||
|
||||
# The oversized catalog is skipped; the healthy catalog's integrations are returned.
|
||||
results = cat.search()
|
||||
ids = [r["id"] for r in results]
|
||||
assert "good-agent" in ids
|
||||
|
||||
def test_clear_cache(self, tmp_path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
@@ -783,23 +713,19 @@ class TestIntegrationListCatalog:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
self._offset = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size == -1:
|
||||
chunk = self._data[self._offset:]
|
||||
self._offset = len(self._data)
|
||||
else:
|
||||
chunk = self._data[self._offset:self._offset + size]
|
||||
self._offset += len(chunk)
|
||||
self._pos = 0
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
@@ -185,7 +185,6 @@ class TestClineIntegration(MarkdownIntegrationTests):
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
files.append(".specify/.gitignore")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in [
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class TestCopilotCommandsMode:
|
||||
class TestCopilotIntegration:
|
||||
def test_copilot_key_and_config(self):
|
||||
copilot = get_integration("copilot")
|
||||
assert copilot is not None
|
||||
@@ -28,7 +28,7 @@ class TestCopilotCommandsMode:
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
created = copilot.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
agent_files = [f for f in created if ".agent." in f.name]
|
||||
assert len(agent_files) > 0
|
||||
@@ -36,11 +36,36 @@ class TestCopilotCommandsMode:
|
||||
assert f.parent == tmp_path / ".github" / "agents"
|
||||
assert f.name.endswith(".agent.md")
|
||||
|
||||
def test_setup_warns_legacy_markdown_default_is_deprecated(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
with pytest.warns(UserWarning, match="Copilot legacy markdown mode is deprecated"):
|
||||
created = copilot.setup(tmp_path, m)
|
||||
|
||||
assert any(f.name.endswith(".agent.md") for f in created)
|
||||
|
||||
def test_skills_setup_does_not_warn_about_legacy_default(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
|
||||
|
||||
assert not any(
|
||||
"Copilot legacy markdown mode is deprecated" in str(item.message)
|
||||
for item in caught
|
||||
)
|
||||
assert any(f.name == "SKILL.md" for f in created)
|
||||
|
||||
def test_setup_creates_companion_prompts(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
created = copilot.setup(tmp_path, m)
|
||||
prompt_files = [f for f in created if f.parent.name == "prompts"]
|
||||
assert len(prompt_files) > 0
|
||||
for f in prompt_files:
|
||||
@@ -52,7 +77,7 @@ class TestCopilotCommandsMode:
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
created = copilot.setup(tmp_path, m)
|
||||
agents = [f for f in created if ".agent.md" in f.name]
|
||||
prompts = [f for f in created if ".prompt.md" in f.name]
|
||||
assert len(agents) == len(prompts)
|
||||
@@ -62,7 +87,7 @@ class TestCopilotCommandsMode:
|
||||
copilot = CopilotIntegration()
|
||||
assert copilot._vscode_settings_path() is not None
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
created = copilot.setup(tmp_path, m)
|
||||
settings = tmp_path / ".vscode" / "settings.json"
|
||||
assert settings.exists()
|
||||
assert settings in created
|
||||
@@ -76,7 +101,7 @@ class TestCopilotCommandsMode:
|
||||
existing = {"editor.fontSize": 14, "custom.setting": True}
|
||||
(vscode_dir / "settings.json").write_text(json.dumps(existing, indent=4), encoding="utf-8")
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
created = copilot.setup(tmp_path, m)
|
||||
settings = tmp_path / ".vscode" / "settings.json"
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
assert data["editor.fontSize"] == 14
|
||||
@@ -94,7 +119,7 @@ class TestCopilotCommandsMode:
|
||||
settings.write_bytes(original)
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
copilot.setup(tmp_path, m)
|
||||
|
||||
assert settings.read_bytes() == original
|
||||
assert "Could not parse" in caplog.text
|
||||
@@ -103,7 +128,7 @@ class TestCopilotCommandsMode:
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
created = copilot.setup(tmp_path, m)
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"Created file {rel} not tracked in manifest"
|
||||
@@ -112,9 +137,7 @@ class TestCopilotCommandsMode:
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.install(
|
||||
tmp_path, m, parsed_options={"commands": True}
|
||||
)
|
||||
created = copilot.install(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
@@ -127,9 +150,7 @@ class TestCopilotCommandsMode:
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.install(
|
||||
tmp_path, m, parsed_options={"commands": True}
|
||||
)
|
||||
created = copilot.install(tmp_path, m)
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
@@ -141,7 +162,7 @@ class TestCopilotCommandsMode:
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
copilot.setup(tmp_path, m)
|
||||
agents_dir = tmp_path / ".github" / "agents"
|
||||
assert agents_dir.is_dir()
|
||||
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
|
||||
@@ -157,7 +178,7 @@ class TestCopilotCommandsMode:
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
copilot.setup(tmp_path, m)
|
||||
agents_dir = tmp_path / ".github" / "agents"
|
||||
for agent_file in agents_dir.glob("speckit.*.agent.md"):
|
||||
content = agent_file.read_text(encoding="utf-8")
|
||||
@@ -172,7 +193,7 @@ class TestCopilotCommandsMode:
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
copilot.setup(tmp_path, m)
|
||||
|
||||
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
|
||||
content = specify_file.read_text(encoding="utf-8")
|
||||
@@ -188,7 +209,7 @@ class TestCopilotCommandsMode:
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
copilot.setup(tmp_path, m)
|
||||
|
||||
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
|
||||
content = specify_file.read_text(encoding="utf-8")
|
||||
@@ -212,7 +233,7 @@ class TestCopilotCommandsMode:
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
copilot.setup(tmp_path, m)
|
||||
|
||||
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
|
||||
content = specify_file.read_text(encoding="utf-8")
|
||||
@@ -225,14 +246,14 @@ class TestCopilotCommandsMode:
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
||||
copilot.setup(tmp_path, m)
|
||||
plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by Copilot commands mode with shell scripts."""
|
||||
"""Every file produced by specify init --integration copilot --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "inventory-sh"
|
||||
@@ -241,8 +262,7 @@ class TestCopilotCommandsMode:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--commands", "--script", "sh",
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -274,7 +294,6 @@ class TestCopilotCommandsMode:
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/.gitignore",
|
||||
".specify/scripts/bash/check-prerequisites.sh",
|
||||
".specify/scripts/bash/common.sh",
|
||||
".specify/scripts/bash/create-new-feature.sh",
|
||||
@@ -296,7 +315,7 @@ class TestCopilotCommandsMode:
|
||||
)
|
||||
|
||||
def test_complete_file_inventory_ps(self, tmp_path):
|
||||
"""Every file produced by Copilot commands mode with PowerShell scripts."""
|
||||
"""Every file produced by specify init --integration copilot --script ps."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "inventory-ps"
|
||||
@@ -305,8 +324,7 @@ class TestCopilotCommandsMode:
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--commands", "--script", "ps",
|
||||
"init", "--here", "--integration", "copilot", "--script", "ps",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -338,7 +356,6 @@ class TestCopilotCommandsMode:
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/.gitignore",
|
||||
".specify/scripts/powershell/check-prerequisites.ps1",
|
||||
".specify/scripts/powershell/common.ps1",
|
||||
".specify/scripts/powershell/create-new-feature.ps1",
|
||||
@@ -359,8 +376,54 @@ class TestCopilotCommandsMode:
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
|
||||
def test_default_cli_init_warns_legacy_markdown_is_deprecated(self, tmp_path):
|
||||
"""Default Copilot init should warn users about the future skills default."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "default-warning"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
with pytest.warns(
|
||||
UserWarning,
|
||||
match="Copilot legacy markdown mode is deprecated",
|
||||
):
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
def test_skills_cli_init_does_not_warn_about_legacy_markdown(self, tmp_path):
|
||||
"""Explicit Copilot skills mode should not warn about the legacy default."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "skills-no-warning"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not any(
|
||||
"Copilot legacy markdown mode is deprecated" in str(item.message)
|
||||
for item in caught
|
||||
)
|
||||
|
||||
|
||||
class TestCopilotSkillsMode:
|
||||
"""Tests for Copilot's default skills mode."""
|
||||
"""Tests for Copilot integration in --skills mode."""
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "clarify", "constitution", "converge", "implement",
|
||||
@@ -373,7 +436,7 @@ class TestCopilotSkillsMode:
|
||||
|
||||
def _setup_skills(self, copilot, tmp_path):
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m)
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
|
||||
return created, m
|
||||
|
||||
# -- Options ----------------------------------------------------------
|
||||
@@ -386,137 +449,6 @@ class TestCopilotSkillsMode:
|
||||
assert skills_opts[0].is_flag is True
|
||||
assert skills_opts[0].default is False
|
||||
|
||||
def test_options_include_commands_flag(self):
|
||||
copilot = get_integration("copilot")
|
||||
commands_opts = [o for o in copilot.options() if o.name == "--commands"]
|
||||
assert len(commands_opts) == 1
|
||||
assert commands_opts[0].is_flag is True
|
||||
assert commands_opts[0].default is False
|
||||
|
||||
def test_default_is_skills_mode(self):
|
||||
copilot = self._make_copilot()
|
||||
assert copilot.is_skills_mode() is True
|
||||
assert copilot.is_skills_mode({}) is True
|
||||
|
||||
def test_commands_flag_disables_skills_mode(self):
|
||||
copilot = self._make_copilot()
|
||||
assert copilot.is_skills_mode({"commands": True}) is False
|
||||
|
||||
def test_existing_commands_layout_is_preserved(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
agents_dir = tmp_path / ".github" / "agents"
|
||||
agents_dir.mkdir(parents=True)
|
||||
(agents_dir / "speckit.plan.agent.md").write_text(
|
||||
"# plan\n", encoding="utf-8"
|
||||
)
|
||||
assert copilot.is_skills_mode(project_root=tmp_path) is False
|
||||
|
||||
def test_setup_preserves_existing_commands_without_stored_options(
|
||||
self, tmp_path
|
||||
):
|
||||
copilot = self._make_copilot()
|
||||
agents_dir = tmp_path / ".github" / "agents"
|
||||
agents_dir.mkdir(parents=True)
|
||||
(agents_dir / "speckit.plan.agent.md").write_text(
|
||||
"# old plan\n", encoding="utf-8"
|
||||
)
|
||||
manifest = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
created = copilot.setup(tmp_path, manifest)
|
||||
|
||||
assert any(path.name.endswith(".agent.md") for path in created)
|
||||
assert not (tmp_path / ".github" / "skills").exists()
|
||||
assert copilot._skills_mode is False
|
||||
|
||||
def test_existing_skills_layout_stays_in_skills_mode(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
(tmp_path / ".github" / "skills" / "speckit-plan").mkdir(parents=True)
|
||||
assert copilot.is_skills_mode(project_root=tmp_path) is True
|
||||
|
||||
def test_commands_manifest_wins_over_untracked_skill(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
manifest = IntegrationManifest("copilot", tmp_path)
|
||||
copilot.setup(
|
||||
tmp_path, manifest, parsed_options={"commands": True}
|
||||
)
|
||||
manifest.save()
|
||||
stale_skill = (
|
||||
tmp_path
|
||||
/ ".github"
|
||||
/ "skills"
|
||||
/ "speckit-plan"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
stale_skill.parent.mkdir(parents=True)
|
||||
stale_skill.write_text("# user-authored skill\n", encoding="utf-8")
|
||||
|
||||
assert copilot.is_skills_mode(project_root=tmp_path) is False
|
||||
|
||||
def test_skills_manifest_wins_over_untracked_command(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
manifest = IntegrationManifest("copilot", tmp_path)
|
||||
copilot.setup(tmp_path, manifest)
|
||||
manifest.save()
|
||||
stale_agent = (
|
||||
tmp_path
|
||||
/ ".github"
|
||||
/ "agents"
|
||||
/ "speckit.plan.agent.md"
|
||||
)
|
||||
stale_agent.parent.mkdir(parents=True)
|
||||
stale_agent.write_text("# stale command\n", encoding="utf-8")
|
||||
|
||||
assert copilot.is_skills_mode(project_root=tmp_path) is True
|
||||
|
||||
def test_explicit_skills_forces_migration_from_commands(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
agents_dir = tmp_path / ".github" / "agents"
|
||||
agents_dir.mkdir(parents=True)
|
||||
(agents_dir / "speckit.plan.agent.md").write_text(
|
||||
"# plan\n", encoding="utf-8"
|
||||
)
|
||||
assert (
|
||||
copilot.is_skills_mode({"skills": True}, project_root=tmp_path)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_skills_and_commands_flags_are_mutually_exclusive(self):
|
||||
copilot = self._make_copilot()
|
||||
with pytest.raises(typer.Exit):
|
||||
copilot.is_skills_mode({"skills": True, "commands": True})
|
||||
|
||||
def test_cli_rejects_skills_and_commands_together(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "conflicting-modes"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
"copilot",
|
||||
"--integration-options",
|
||||
"--skills --commands",
|
||||
"--script",
|
||||
"sh",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "--skills and --commands are mutually exclusive" in result.output
|
||||
assert not (project / ".github" / "skills").exists()
|
||||
assert not (project / ".github" / "agents").exists()
|
||||
|
||||
# -- Skills directory structure ---------------------------------------
|
||||
|
||||
def test_skills_creates_skill_files(self, tmp_path):
|
||||
@@ -691,16 +623,16 @@ class TestCopilotSkillsMode:
|
||||
def test_skills_mode_invoke_separator(self):
|
||||
"""Copilot effective_invoke_separator should reflect skills mode."""
|
||||
copilot = self._make_copilot()
|
||||
assert copilot.effective_invoke_separator() == "-"
|
||||
assert copilot.effective_invoke_separator() == "."
|
||||
assert copilot.effective_invoke_separator({"skills": True}) == "-"
|
||||
assert copilot.effective_invoke_separator({"commands": True}) == "."
|
||||
assert copilot.effective_invoke_separator({"skills": False}) == "."
|
||||
|
||||
def test_invoke_separator_for_mode_tracks_persisted_state(self):
|
||||
"""Regression (review #3415): registration paths (preset/extension
|
||||
command refs) must resolve the separator from the persisted ai_skills
|
||||
state. A Copilot skills project renders ``/speckit-<cmd>`` (hyphen),
|
||||
matching ``build_command_invocation``; commands mode renders
|
||||
``/speckit.<cmd>`` (dot).
|
||||
matching ``build_command_invocation``; the default markdown layout
|
||||
renders ``/speckit.<cmd>`` (dot).
|
||||
"""
|
||||
copilot = self._make_copilot()
|
||||
assert copilot.invoke_separator_for_mode(True) == "-"
|
||||
@@ -741,7 +673,7 @@ class TestCopilotSkillsMode:
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.install(tmp_path, m)
|
||||
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
@@ -753,7 +685,7 @@ class TestCopilotSkillsMode:
|
||||
def test_modified_file_survives_uninstall(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.install(tmp_path, m)
|
||||
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
@@ -778,12 +710,6 @@ class TestCopilotSkillsMode:
|
||||
|
||||
def test_build_command_invocation_default_mode(self):
|
||||
copilot = self._make_copilot()
|
||||
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
|
||||
assert copilot.build_command_invocation("plan") == "/speckit-plan"
|
||||
|
||||
def test_build_command_invocation_commands_mode(self):
|
||||
copilot = self._make_copilot()
|
||||
copilot._skills_mode = False
|
||||
assert copilot.build_command_invocation("plan", "my args") == "my args"
|
||||
assert copilot.build_command_invocation("plan") == ""
|
||||
|
||||
@@ -799,8 +725,8 @@ class TestCopilotSkillsMode:
|
||||
|
||||
# -- CLI integration test ---------------------------------------------
|
||||
|
||||
def test_init_defaults_to_skills(self, tmp_path):
|
||||
"""specify init --integration copilot scaffolds skills by default."""
|
||||
def test_init_with_integration_options_skills(self, tmp_path):
|
||||
"""specify init --integration copilot --integration-options='--skills' scaffolds skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "copilot-skills"
|
||||
@@ -810,6 +736,7 @@ class TestCopilotSkillsMode:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -825,7 +752,7 @@ class TestCopilotSkillsMode:
|
||||
assert not (project / ".vscode" / "settings.json").exists()
|
||||
|
||||
def test_complete_file_inventory_skills_sh(self, tmp_path):
|
||||
"""Every file produced by default Copilot init with shell scripts."""
|
||||
"""Every file produced by specify init --integration copilot --integration-options='--skills' --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "inventory-skills-sh"
|
||||
@@ -835,6 +762,7 @@ class TestCopilotSkillsMode:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -849,7 +777,6 @@ class TestCopilotSkillsMode:
|
||||
".specify/integration.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/.gitignore",
|
||||
# Scripts (sh)
|
||||
".specify/scripts/bash/check-prerequisites.sh",
|
||||
".specify/scripts/bash/common.sh",
|
||||
@@ -875,46 +802,36 @@ class TestCopilotSkillsMode:
|
||||
|
||||
# -- Singleton leak: _skills_mode must reset --------------------------
|
||||
|
||||
def test_skills_mode_resets_between_layouts(self, tmp_path):
|
||||
"""setup() must reset the singleton mode for each selected layout."""
|
||||
def test_skills_mode_resets_on_default_setup(self, tmp_path):
|
||||
"""setup() with skills=True then without must reset _skills_mode."""
|
||||
copilot = self._make_copilot()
|
||||
|
||||
# First call: default skills mode
|
||||
# First call: skills mode
|
||||
(tmp_path / "proj1").mkdir()
|
||||
m1 = IntegrationManifest("copilot", tmp_path / "proj1")
|
||||
copilot.setup(tmp_path / "proj1", m1)
|
||||
copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True})
|
||||
assert copilot._skills_mode is True
|
||||
|
||||
# Second call: explicit commands mode
|
||||
# Second call: default mode (no skills option)
|
||||
(tmp_path / "proj2").mkdir()
|
||||
m2 = IntegrationManifest("copilot", tmp_path / "proj2")
|
||||
copilot.setup(
|
||||
tmp_path / "proj2", m2, parsed_options={"commands": True}
|
||||
)
|
||||
copilot.setup(tmp_path / "proj2", m2)
|
||||
assert copilot._skills_mode is False
|
||||
|
||||
# build_command_invocation must use default (dotted) mode
|
||||
assert copilot.build_command_invocation("plan", "args") == "args"
|
||||
|
||||
# Third call: a fresh default project must switch back to skills.
|
||||
(tmp_path / "proj3").mkdir()
|
||||
m3 = IntegrationManifest("copilot", tmp_path / "proj3")
|
||||
copilot.setup(tmp_path / "proj3", m3)
|
||||
assert copilot._skills_mode is True
|
||||
assert copilot.build_command_invocation("plan") == "/speckit-plan"
|
||||
# -- Auto-detection must ignore unrelated .github/skills/ -------------
|
||||
|
||||
# -- Auto-detection must preserve managed commands --------------------
|
||||
|
||||
def test_dispatch_preserves_commands_with_unrelated_skills(self, tmp_path):
|
||||
"""Unrelated skills must not migrate a managed commands layout."""
|
||||
def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path):
|
||||
"""dispatch_command() must not treat unrelated .github/skills/ as skills mode."""
|
||||
copilot = self._make_copilot()
|
||||
agents_dir = tmp_path / ".github" / "agents"
|
||||
agents_dir.mkdir(parents=True)
|
||||
(agents_dir / "speckit.plan.agent.md").write_text(
|
||||
"# plan\n", encoding="utf-8"
|
||||
)
|
||||
# Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training)
|
||||
unrelated = tmp_path / ".github" / "skills" / "introduction-to-github"
|
||||
unrelated.mkdir(parents=True)
|
||||
(unrelated / "README.md").write_text("# GitHub Skills training\n")
|
||||
|
||||
# Should NOT detect skills mode — cli_args should contain --agent
|
||||
import unittest.mock as mock
|
||||
with mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
@@ -951,7 +868,7 @@ class TestCopilotSkillsMode:
|
||||
# -- Next-steps display for Copilot skills mode -----------------------
|
||||
|
||||
def test_init_skills_next_steps_show_skill_syntax(self, tmp_path):
|
||||
"""Default Copilot init shows /speckit-plan, not /speckit.plan."""
|
||||
"""specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "copilot-nextsteps"
|
||||
@@ -961,6 +878,7 @@ class TestCopilotSkillsMode:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
@@ -342,7 +342,6 @@ class TestGenericIntegration:
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/.gitignore",
|
||||
".specify/memory/.constitution-template.json",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/scripts/bash/check-prerequisites.sh",
|
||||
@@ -400,7 +399,6 @@ class TestGenericIntegration:
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/.gitignore",
|
||||
".specify/memory/.constitution-template.json",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/scripts/powershell/check-prerequisites.ps1",
|
||||
|
||||
@@ -89,10 +89,10 @@ def test_write_integration_json_strips_integration_key(tmp_path):
|
||||
def test_with_integration_setting_recomputes_separator_from_retained_options():
|
||||
"""Updating only script_type must not drop an options-dependent separator.
|
||||
|
||||
Copilot resolves the command-ref separator to '.' when '--commands' is
|
||||
stored and '-' by default. A second call that changes only script_type
|
||||
Copilot resolves the command-ref separator to '-' when '--skills' options
|
||||
are stored and '.' otherwise. A second call that changes only script_type
|
||||
(parsed_options=None, raw_options=None) retains the stored parsed_options,
|
||||
so invoke_separator must stay '.', not be recomputed from the None argument.
|
||||
so invoke_separator must stay '-', not be recomputed from the None argument.
|
||||
"""
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integration_runtime import with_integration_setting
|
||||
@@ -100,15 +100,15 @@ def test_with_integration_setting_recomputes_separator_from_retained_options():
|
||||
copilot = get_integration("copilot")
|
||||
|
||||
settings = with_integration_setting(
|
||||
{}, "copilot", copilot, parsed_options={"commands": True}
|
||||
{}, "copilot", copilot, parsed_options={"skills": True}
|
||||
)
|
||||
assert settings["copilot"]["invoke_separator"] == "."
|
||||
assert settings["copilot"]["invoke_separator"] == "-"
|
||||
|
||||
settings2 = with_integration_setting(
|
||||
{"integration_settings": settings}, "copilot", copilot, script_type="ps"
|
||||
)
|
||||
# parsed_options are retained (only script_type changed) ...
|
||||
assert settings2["copilot"]["parsed_options"] == {"commands": True}
|
||||
assert settings2["copilot"]["parsed_options"] == {"skills": True}
|
||||
assert settings2["copilot"]["script"] == "ps"
|
||||
# ... so the separator must reflect them, not the (None) argument.
|
||||
assert settings2["copilot"]["invoke_separator"] == "."
|
||||
assert settings2["copilot"]["invoke_separator"] == "-"
|
||||
|
||||
@@ -2224,99 +2224,15 @@ class TestIntegrationSwitch:
|
||||
# Old claude files removed
|
||||
assert not (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
# New default Copilot skills created
|
||||
assert (
|
||||
project / ".github" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
).exists()
|
||||
assert "/speckit-specify" in shared_script.read_text(encoding="utf-8")
|
||||
assert "/speckit.specify" not in shared_script.read_text(encoding="utf-8")
|
||||
# New copilot files created
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
assert "/speckit.specify" in shared_script.read_text(encoding="utf-8")
|
||||
assert "/speckit-specify" not in shared_script.read_text(encoding="utf-8")
|
||||
|
||||
# integration.json updated
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "copilot"
|
||||
|
||||
def test_switch_rejects_conflicting_copilot_modes_before_uninstall(
|
||||
self, tmp_path
|
||||
):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
claude_skill = (
|
||||
project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
)
|
||||
before_state = json.loads(
|
||||
(project / ".specify" / "integration.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
result = _run_in_project(
|
||||
project,
|
||||
[
|
||||
"integration",
|
||||
"switch",
|
||||
"copilot",
|
||||
"--integration-options",
|
||||
"--skills --commands",
|
||||
"--script",
|
||||
"sh",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "--skills and --commands are mutually exclusive" in result.output
|
||||
assert claude_skill.exists()
|
||||
assert not (project / ".github" / "skills").exists()
|
||||
assert not (project / ".github" / "agents").exists()
|
||||
after_state = json.loads(
|
||||
(project / ".specify" / "integration.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert after_state == before_state
|
||||
|
||||
def test_switch_preserves_target_options_with_fallback_integration(
|
||||
self, tmp_path
|
||||
):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
install = _run_in_project(
|
||||
project,
|
||||
[
|
||||
"integration",
|
||||
"install",
|
||||
"opencode",
|
||||
"--script",
|
||||
"sh",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
result = _run_in_project(
|
||||
project,
|
||||
[
|
||||
"integration",
|
||||
"switch",
|
||||
"copilot",
|
||||
"--integration-options",
|
||||
"--commands",
|
||||
"--script",
|
||||
"sh",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (
|
||||
project / ".github" / "agents" / "speckit.plan.agent.md"
|
||||
).exists()
|
||||
assert not (project / ".github" / "skills").exists()
|
||||
state = json.loads(
|
||||
(project / ".specify" / "integration.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert state["integration_settings"]["copilot"]["parsed_options"] == {
|
||||
"commands": True
|
||||
}
|
||||
|
||||
def test_switch_migrates_extension_commands(self, tmp_path):
|
||||
"""Switching should migrate extension commands to the new agent directory."""
|
||||
project = _init_project(tmp_path, "kimi")
|
||||
@@ -2576,7 +2492,6 @@ class TestIntegrationSwitch:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--integration-options", "--commands",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -2615,7 +2530,6 @@ class TestIntegrationSwitch:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--integration-options", "--commands",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -2644,7 +2558,6 @@ class TestIntegrationSwitch:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--integration-options", "--commands",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -2669,7 +2582,6 @@ class TestIntegrationSwitch:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, [
|
||||
"integration", "switch", "copilot",
|
||||
"--integration-options", "--commands",
|
||||
"--script", "sh",
|
||||
"--refresh-shared-infra",
|
||||
], catch_exceptions=False)
|
||||
@@ -2982,9 +2894,7 @@ class TestIntegrationUpgrade:
|
||||
assert manifest_path.read_text(encoding="utf-8") == before_manifest
|
||||
|
||||
def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_change(self, tmp_path):
|
||||
project = _init_project(
|
||||
tmp_path, "copilot", integration_options="--commands"
|
||||
)
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
managed_script = project / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
customized_script = project / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
@@ -3006,46 +2916,6 @@ class TestIntegrationUpgrade:
|
||||
assert "/speckit.specify" not in managed_content
|
||||
assert customized_script.read_text(encoding="utf-8") == customized_before
|
||||
|
||||
def test_upgrade_preserves_historical_copilot_commands_without_options(
|
||||
self, tmp_path
|
||||
):
|
||||
"""A command manifest restores missing files instead of migrating."""
|
||||
project = _init_project(
|
||||
tmp_path, "copilot", integration_options="--commands"
|
||||
)
|
||||
state_path = project / ".specify" / "integration.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
copilot_settings = state["integration_settings"]["copilot"]
|
||||
copilot_settings.pop("raw_options", None)
|
||||
copilot_settings.pop("parsed_options", None)
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
for path in (project / ".github" / "agents").glob(
|
||||
"speckit.*.agent.md"
|
||||
):
|
||||
path.unlink()
|
||||
for path in (project / ".github" / "prompts").glob(
|
||||
"speckit.*.prompt.md"
|
||||
):
|
||||
path.unlink()
|
||||
|
||||
result = _run_in_project(
|
||||
project,
|
||||
["integration", "upgrade", "copilot", "--script", "sh", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (
|
||||
project / ".github" / "agents" / "speckit.plan.agent.md"
|
||||
).exists()
|
||||
assert not (project / ".github" / "skills").exists()
|
||||
init_options = json.loads(
|
||||
(project / ".specify" / "init-options.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert init_options.get("ai_skills") is not True
|
||||
|
||||
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
|
||||
project = _init_project(tmp_path, "gemini")
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
@@ -3851,9 +3721,7 @@ class TestIntegrationUpgrade:
|
||||
tracking it, so without ``stale_cleanup_exclusions()`` the Phase 2
|
||||
stale cleanup would delete it (destroying the user's settings).
|
||||
"""
|
||||
project = _init_project(
|
||||
tmp_path, "copilot", integration_options="--commands"
|
||||
)
|
||||
project = _init_project(tmp_path, "copilot")
|
||||
settings = project / ".vscode" / "settings.json"
|
||||
assert settings.is_file(), "init should create .vscode/settings.json"
|
||||
before = json.loads(settings.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -178,66 +178,3 @@ class TestHermesSkillFrontmatterQuoting:
|
||||
|
||||
fm = _parse_frontmatter(skill_files[0])
|
||||
assert fm["description"] == MULTILINE
|
||||
|
||||
def test_dashed_description_is_preserved(self, tmp_path, monkeypatch):
|
||||
"""Hermes overrides setup(), so it needs the same line-anchored parse."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
integration = get_integration("hermes")
|
||||
monkeypatch.setattr(
|
||||
integration,
|
||||
"shared_commands_dir",
|
||||
lambda: _fake_templates(tmp_path, DASHED_TEMPLATE),
|
||||
)
|
||||
manifest = IntegrationManifest("hermes", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) == 1
|
||||
|
||||
fm = _parse_frontmatter_line_anchored(skill_files[0])
|
||||
assert fm["description"] == DASHED_DESCRIPTION
|
||||
|
||||
content = skill_files[0].read_text(encoding="utf-8")
|
||||
lines = content.splitlines(keepends=True)
|
||||
end = next(i for i in range(1, len(lines)) if lines[i].rstrip() == "---")
|
||||
body = "".join(lines[end + 1 :])
|
||||
assert "name-marker: sentinel" not in body
|
||||
|
||||
|
||||
class TestKimiGeneratedSkillDetection:
|
||||
"""``_is_speckit_generated_skill`` must survive a ``---`` in a value.
|
||||
|
||||
Teardown only removes a legacy skill directory it recognizes as
|
||||
Speckit-generated via the frontmatter ``metadata`` block. A substring split
|
||||
truncated the frontmatter before ``metadata`` when a description embedded
|
||||
``---``, so the directory was left behind on uninstall.
|
||||
"""
|
||||
|
||||
def _write_skill(self, skill_dir: Path, description: str) -> None:
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
'name: "speckit-plan"\n'
|
||||
f"description: {description}\n"
|
||||
"metadata:\n"
|
||||
' author: "github-spec-kit"\n'
|
||||
' source: "templates/commands/plan.md"\n'
|
||||
"---\n\nBody.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def test_detects_skill_with_dashes_in_description(self, tmp_path):
|
||||
from specify_cli.integrations.kimi import _is_speckit_generated_skill
|
||||
|
||||
skill_dir = tmp_path / "speckit-plan"
|
||||
self._write_skill(skill_dir, "Separate sections with --- markers")
|
||||
assert _is_speckit_generated_skill(skill_dir) is True
|
||||
|
||||
def test_still_detects_plain_description(self, tmp_path):
|
||||
from specify_cli.integrations.kimi import _is_speckit_generated_skill
|
||||
|
||||
skill_dir = tmp_path / "speckit-plan"
|
||||
self._write_skill(skill_dir, "Plain description")
|
||||
assert _is_speckit_generated_skill(skill_dir) is True
|
||||
|
||||
@@ -181,52 +181,6 @@ def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
|
||||
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
|
||||
|
||||
|
||||
def test_python_text_output_survives_a_legacy_stdout_code_page(
|
||||
prereq_repo: Path,
|
||||
) -> None:
|
||||
"""Text mode must not crash when stdout cannot encode the status glyphs.
|
||||
|
||||
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
|
||||
console — which is every time an agent or a workflow step captures the
|
||||
output. U+2713 is unencodable in cp1252, so printing it raised
|
||||
UnicodeEncodeError and truncated the report right after "AVAILABLE_DOCS:".
|
||||
The ASCII fallback is the rendering these markers already have in-tree
|
||||
(Test-FileExists in scripts/powershell/common.ps1, and
|
||||
normalize_status_text here).
|
||||
"""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
# research.md is present and the rest are not, so BOTH status markers are
|
||||
# produced in the same cp1252 subprocess: U+2713 for the available document
|
||||
# and U+2717 for the missing ones. Asserting only one of them would let a
|
||||
# fallback that always returned "[FAIL]" pass.
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "contracts").mkdir() # present but empty -> reported missing
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
env = _clean_env()
|
||||
env["PYTHONIOENCODING"] = "cp1252"
|
||||
result = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo, env=env)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "UnicodeEncodeError" not in result.stderr
|
||||
assert "AVAILABLE_DOCS:" in result.stdout
|
||||
# Every per-document line must still be there, not truncated away by the
|
||||
# encode error.
|
||||
for doc in (
|
||||
"research.md",
|
||||
"data-model.md",
|
||||
"contracts/",
|
||||
"quickstart.md",
|
||||
"tasks.md",
|
||||
):
|
||||
assert doc in result.stdout, (doc, result.stdout)
|
||||
# Both fallback markers, so neither branch of _status_marker can regress.
|
||||
assert "[OK] research.md" in result.stdout, result.stdout
|
||||
assert "[FAIL] quickstart.md" in result.stdout, result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
|
||||
@@ -475,194 +475,6 @@ def test_safe_extract_tar_enforces_entry_and_size_limits(tmp_path):
|
||||
safe_extract_tar(archive_path, tmp_path / "total", max_total_bytes=7)
|
||||
|
||||
|
||||
def _truncated_tar_gz_bytes(keep_bytes):
|
||||
"""Return the leading *keep_bytes* of a multi-member tar.gz's bytes.
|
||||
|
||||
A gzip stream cut short this way ends before its end-of-stream marker, so
|
||||
reading it raises a bare ``EOFError`` from the gzip layer. ``tarfile``
|
||||
decompresses lazily, so *where* that surfaces depends on how much is kept:
|
||||
a very short prefix fails in ``tarfile.open`` itself, while a longer one
|
||||
opens fine and only fails once members are iterated.
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
for index in range(5):
|
||||
info = tarfile.TarInfo(f"file{index}.txt")
|
||||
content = bytes(range(256)) * 400
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
return buffer.getvalue()[:keep_bytes]
|
||||
|
||||
|
||||
def test_detect_archive_format_rejects_truncated_tar_gz(tmp_path):
|
||||
# A gzip stream truncated before tarfile can read its first header raises a
|
||||
# bare EOFError -- not a TarError -- from the format probe. Catching only
|
||||
# TarError let it escape as a raw exception instead of leaving is_tar_gz
|
||||
# False and reporting the module's clean format-mismatch error.
|
||||
archive_path = tmp_path / "truncated.tar.gz"
|
||||
archive_path.write_bytes(_truncated_tar_gz_bytes(64))
|
||||
|
||||
with pytest.raises(ValueError, match="format mismatch"):
|
||||
detect_archive_format(archive_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keep_bytes", [64, 512, 2048])
|
||||
def test_safe_extract_tar_rejects_truncated_archive(tmp_path, keep_bytes):
|
||||
# The same bare EOFError, from tarfile.open on a short prefix and from
|
||||
# member iteration on a longer one. Both sites reported it raw.
|
||||
archive_path = tmp_path / f"truncated-{keep_bytes}.tar.gz"
|
||||
archive_path.write_bytes(_truncated_tar_gz_bytes(keep_bytes))
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid tar.gz archive"):
|
||||
safe_extract_tar(archive_path, tmp_path / f"out-{keep_bytes}")
|
||||
|
||||
|
||||
def test_safe_extract_tar_wraps_truncation_in_caller_error_type(tmp_path):
|
||||
# The leak bypassed the caller's domain error type entirely, so callers
|
||||
# that only catch their own error (or ValueError) crashed the command.
|
||||
archive_path = tmp_path / "truncated.tar.gz"
|
||||
archive_path.write_bytes(_truncated_tar_gz_bytes(2048))
|
||||
|
||||
with pytest.raises(_CustomZipError, match="Invalid tar.gz archive"):
|
||||
safe_extract_tar(
|
||||
archive_path,
|
||||
tmp_path / "out",
|
||||
error_type=_CustomZipError,
|
||||
)
|
||||
|
||||
|
||||
def test_safe_extract_archive_rejects_truncated_tar_gz(tmp_path):
|
||||
archive_path = tmp_path / "truncated.tar.gz"
|
||||
archive_path.write_bytes(_truncated_tar_gz_bytes(2048))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
safe_extract_archive(archive_path, tmp_path / "out")
|
||||
|
||||
|
||||
#: Bytes of the first member's data that decompress cleanly before the invalid
|
||||
#: deflate block. Must exceed the gzip read buffer so ``tarfile`` has to seek
|
||||
#: forward over member data to reach the second header -- see
|
||||
#: ``_corrupt_deflate_tar_gz_bytes``. The members are twice this size, so the
|
||||
#: corruption stays well inside the first member's data.
|
||||
_CORRUPT_DEFLATE_CLEAN_BYTES = 256 * 1024
|
||||
_CORRUPT_DEFLATE_MEMBER_BYTES = 2 * _CORRUPT_DEFLATE_CLEAN_BYTES
|
||||
|
||||
|
||||
def _corrupt_deflate_tar_gz_bytes():
|
||||
"""Return a tar.gz whose deflate stream is corrupt mid-member.
|
||||
|
||||
Unlike truncation, which the gzip layer reports as ``EOFError``, an invalid
|
||||
deflate block raises ``zlib.error``. ``tarfile`` converts that to
|
||||
``ReadError`` when it surfaces while reading a member *header*, but the
|
||||
forward seek it performs to skip over member *data* sits outside that
|
||||
conversion, so the raw ``zlib.error`` escapes from there.
|
||||
|
||||
Two details keep this deterministic across zlib versions:
|
||||
|
||||
* The corruption is a block header whose ``BTYPE`` is the reserved value
|
||||
``0b11``, which every zlib rejects as "invalid block type". Mangling
|
||||
arbitrary bytes instead is *not* portable -- the garbage may still decode
|
||||
structurally and fail the later gzip CRC check as ``BadGzipFile`` (an
|
||||
``OSError``, which the handler already caught) rather than raising
|
||||
``zlib.error`` at all.
|
||||
* The stream is assembled by hand so the invalid block lands after
|
||||
``_CORRUPT_DEFLATE_CLEAN_BYTES`` of valid data. That is past the gzip read
|
||||
buffer, so the first header reads clean and the failure happens during the
|
||||
seek over member data rather than during a header read.
|
||||
"""
|
||||
plain = io.BytesIO()
|
||||
with tarfile.open(fileobj=plain, mode="w") as archive:
|
||||
for index in range(2):
|
||||
info = tarfile.TarInfo(f"file{index}.txt")
|
||||
content = bytes((i * 7 + index) % 256 for i in range(1024)) * (
|
||||
_CORRUPT_DEFLATE_MEMBER_BYTES // 1024
|
||||
)
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
|
||||
clean_prefix = plain.getvalue()[:_CORRUPT_DEFLATE_CLEAN_BYTES]
|
||||
compressor = zlib.compressobj(1, zlib.DEFLATED, -15)
|
||||
deflate = compressor.compress(clean_prefix)
|
||||
deflate += compressor.flush(zlib.Z_SYNC_FLUSH)
|
||||
deflate += b"\x06" # BTYPE=0b11 (reserved) -> "invalid block type"
|
||||
|
||||
gzip_header = b"\x1f\x8b\x08\x00" + b"\x00" * 4 + b"\x00\xff"
|
||||
trailer = struct.pack("<II", zlib.crc32(clean_prefix), len(clean_prefix))
|
||||
return gzip_header + deflate + trailer
|
||||
|
||||
|
||||
def test_corrupt_deflate_fixture_raises_bare_zlib_error():
|
||||
# Guards the fixture itself: the tests below are only meaningful while this
|
||||
# archive reaches the module as a bare zlib.error -- neither a TarError nor
|
||||
# an OSError, so a (TarError, OSError) handler would miss it. If a future
|
||||
# Python or zlib wraps it, this fails loudly instead of the coverage
|
||||
# silently decaying into a duplicate of the EOFError cases.
|
||||
archive_file = io.BytesIO(_corrupt_deflate_tar_gz_bytes())
|
||||
|
||||
with tarfile.open(fileobj=archive_file, mode="r:gz") as archive:
|
||||
with pytest.raises(zlib.error) as excinfo:
|
||||
for _member in archive:
|
||||
pass
|
||||
|
||||
# The whole point of the zlib.error arm: a (TarError, OSError) handler --
|
||||
# what the two extraction sites had before the fix -- does not catch this.
|
||||
# tarfile.ReadError and gzip.BadGzipFile would both be caught already, so
|
||||
# if the fixture ever degrades into one of those it proves nothing.
|
||||
assert not isinstance(excinfo.value, tarfile.TarError)
|
||||
assert not isinstance(excinfo.value, OSError)
|
||||
|
||||
|
||||
def test_detect_archive_format_accepts_corrupt_deflate_tar_gz(tmp_path):
|
||||
# Detection is a format probe, not an integrity check: tarfile.open reads
|
||||
# only the first member header, which is intact here, so the archive is
|
||||
# correctly identified as tar.gz and the corruption is caught later by
|
||||
# safe_extract_tar (see the tests below).
|
||||
#
|
||||
# Note this does not exercise the probe's zlib.error handling, which is
|
||||
# unreachable: the header read is inside tarfile's own
|
||||
# zlib.error -> ReadError conversion, so the probe sees ReadError. The
|
||||
# zlib.error arm of _TAR_DECOMPRESSION_ERRORS is defensive at this site and
|
||||
# load-bearing only at the two safe_extract_tar sites.
|
||||
archive_path = tmp_path / "corrupt.tar.gz"
|
||||
archive_path.write_bytes(_corrupt_deflate_tar_gz_bytes())
|
||||
|
||||
assert detect_archive_format(archive_path) == "tar.gz"
|
||||
|
||||
|
||||
def test_safe_extract_tar_rejects_corrupt_deflate(tmp_path):
|
||||
archive_path = tmp_path / "corrupt.tar.gz"
|
||||
archive_path.write_bytes(_corrupt_deflate_tar_gz_bytes())
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid tar.gz archive"):
|
||||
safe_extract_tar(archive_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_tar_wraps_corrupt_deflate_in_caller_error_type(tmp_path):
|
||||
# zlib.error must reach the caller's domain error type, exactly as EOFError
|
||||
# does, so this cannot regress independently of the truncation handling.
|
||||
archive_path = tmp_path / "corrupt.tar.gz"
|
||||
archive_path.write_bytes(_corrupt_deflate_tar_gz_bytes())
|
||||
|
||||
with pytest.raises(_CustomZipError, match="Invalid tar.gz archive"):
|
||||
safe_extract_tar(
|
||||
archive_path,
|
||||
tmp_path / "out",
|
||||
error_type=_CustomZipError,
|
||||
)
|
||||
|
||||
|
||||
def test_safe_extract_archive_wraps_corrupt_deflate_in_caller_error_type(tmp_path):
|
||||
archive_path = tmp_path / "corrupt.tar.gz"
|
||||
archive_path.write_bytes(_corrupt_deflate_tar_gz_bytes())
|
||||
|
||||
with pytest.raises(_CustomZipError, match="Invalid tar.gz archive"):
|
||||
safe_extract_archive(
|
||||
archive_path,
|
||||
tmp_path / "out",
|
||||
error_type=_CustomZipError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"])
|
||||
def test_safe_extract_archive_has_format_parity(tmp_path, suffix):
|
||||
archive_path = tmp_path / f"package{suffix}"
|
||||
|
||||
@@ -2043,7 +2043,7 @@ class TestExtensionSkillRegistration:
|
||||
assert skill_file.exists(), "sanity: skills mode should write SKILL.md"
|
||||
|
||||
# Toggle ai_skills off for the same active agent (copilot) and
|
||||
# rescaffold, mirroring `integration upgrade copilot --commands`.
|
||||
# rescaffold, mirroring `integration upgrade copilot` (no --skills).
|
||||
_create_init_options(project_dir, ai="copilot", ai_skills=False)
|
||||
manager.register_enabled_extensions_for_agent("copilot")
|
||||
|
||||
@@ -2117,7 +2117,7 @@ class TestExtensionSkillRegistration:
|
||||
)
|
||||
|
||||
# Toggle copilot to command mode (mirroring `integration upgrade
|
||||
# copilot --commands`) — copilot's mirror is now stale.
|
||||
# copilot` with no --skills) — copilot's mirror is now stale.
|
||||
_create_init_options(project_dir, ai="copilot", ai_skills=False)
|
||||
manager.register_enabled_extensions_for_agent("copilot")
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import platform
|
||||
import tempfile
|
||||
import shutil
|
||||
import tomllib
|
||||
import yaml
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
@@ -411,55 +410,6 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid version"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
1.0, # unquoted YAML float -- the likeliest authoring slip
|
||||
5, # unquoted int
|
||||
True, # YAML `yes`/`true`
|
||||
None, # `speckit_version:` written but left empty
|
||||
[">=0.1.0"], # iterable: slips past SpecifierSet() entirely
|
||||
{"min": "0.1"}, # iterable: same
|
||||
],
|
||||
)
|
||||
def test_non_string_speckit_version(self, temp_dir, valid_manifest_data, bad):
|
||||
"""A non-string requires.speckit_version must be a ValidationError.
|
||||
|
||||
It was presence-checked only, so it reached ``SpecifierSet(required)`` in
|
||||
check_compatibility(), which is guarded by ``except InvalidSpecifier``
|
||||
alone. A non-string escapes that guard two ways: scalars raise TypeError
|
||||
from the constructor, and a list/dict is iterable so SpecifierSet accepts
|
||||
it and the failure surfaces later as ``AttributeError: 'str' object has no
|
||||
attribute 'filter'`` from inside .contains().
|
||||
"""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["requires"]["speckit_version"] = bad
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="Invalid requires.speckit_version"
|
||||
):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_empty_speckit_version(self, temp_dir, valid_manifest_data):
|
||||
"""A blank requires.speckit_version must be rejected, not treated as any."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["requires"]["speckit_version"] = " "
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="Invalid requires.speckit_version"
|
||||
):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_valid_category(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with various category values (free-form string)."""
|
||||
import yaml
|
||||
@@ -1291,31 +1241,6 @@ class TestExtensionRegistry:
|
||||
result = registry.list()
|
||||
assert result == {}
|
||||
|
||||
def test_load_starts_fresh_for_non_utf8_registry(self, temp_dir):
|
||||
"""A registry file with undecodable bytes must start fresh, not raise.
|
||||
|
||||
``_load()`` already treats malformed JSON as "corrupted registry,
|
||||
start fresh", but a registry whose *bytes* cannot be decoded as UTF-8
|
||||
raised a raw ``UnicodeDecodeError`` from the text-mode read before
|
||||
JSON parsing began — the same corruption class reaching a different
|
||||
exception type. Because the registry is loaded in ``__init__``, that
|
||||
traceback broke *every* extension command on the project.
|
||||
"""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
(extensions_dir / ExtensionRegistry.REGISTRY_FILE).write_bytes(
|
||||
b"\xff\xfe not utf-8 \xc3\x28"
|
||||
)
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
|
||||
assert registry.data == {
|
||||
"schema_version": ExtensionRegistry.SCHEMA_VERSION,
|
||||
"extensions": {},
|
||||
}
|
||||
assert registry.list() == {}
|
||||
assert not registry.is_installed("test-ext")
|
||||
|
||||
|
||||
# ===== ExtensionManager Tests =====
|
||||
|
||||
@@ -1340,28 +1265,6 @@ class TestExtensionManager:
|
||||
with pytest.raises(CompatibilityError, match="Extension requires spec-kit"):
|
||||
manager.check_compatibility(manifest, "0.0.1")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[1.0, 5, True, None, [">=0.1.0"], {"min": "0.1"}],
|
||||
)
|
||||
def test_check_compatibility_non_string_specifier(self, project_dir, bad):
|
||||
"""check_compatibility() must report a non-string as CompatibilityError.
|
||||
|
||||
Defense in depth for the validator check above: this method is public and
|
||||
reachable with a hand-built manifest, and ``except InvalidSpecifier`` does
|
||||
not cover a non-string. Without the guard, scalars raise a bare TypeError
|
||||
and iterables construct fine only to break inside .contains() -- neither
|
||||
is a CompatibilityError, so both bypass the CLI's "Compatibility Error"
|
||||
handler and exit 1 with a raw traceback naming no field.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = SimpleNamespace(requires_speckit_version=bad)
|
||||
|
||||
with pytest.raises(CompatibilityError, match="Invalid version specifier"):
|
||||
manager.check_compatibility(manifest, "0.15.2")
|
||||
|
||||
def test_check_compatibility_allows_prerelease_builds(self, extension_dir, project_dir):
|
||||
"""Prerelease spec-kit builds should satisfy compatible version ranges."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
@@ -1698,57 +1601,6 @@ class TestExtensionManager:
|
||||
assert external_target.read_text() == "model: linked-model\n"
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
|
||||
def test_reinstall_with_unreadable_kept_config_aborts_with_guidance(
|
||||
self, extension_dir, project_dir, monkeypatch
|
||||
):
|
||||
"""An unreadable kept config must abort reinstall, not crash it.
|
||||
|
||||
The sibling symlink guard four lines above raises ``ValidationError``
|
||||
with resolution guidance, but the rescue read itself
|
||||
(``cfg_file.read_bytes()``/``stat()``) had no boundary, so a kept
|
||||
config that cannot be read (permission or I/O error) crashed the
|
||||
reinstall with a raw ``OSError``. It must reject the reinstall while
|
||||
dest_dir is untouched so the preserved bytes are never rescued
|
||||
half-read or lost to the rmtree below.
|
||||
"""
|
||||
manager = ExtensionManager(project_dir)
|
||||
packaged_config = extension_dir / "test-ext-config.yml"
|
||||
packaged_config.write_text("model: default-model\n")
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
|
||||
config_file = ext_dir / "test-ext-config.yml"
|
||||
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
|
||||
kept_bytes = config_file.read_bytes()
|
||||
|
||||
manager.remove("test-ext", keep_config=True)
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
assert config_file.is_file()
|
||||
|
||||
# Simulate a kept config that can no longer be read (e.g. a
|
||||
# permission or I/O error) without touching real permissions so the
|
||||
# test also runs on platforms where chmod is a no-op.
|
||||
original_read_bytes = Path.read_bytes
|
||||
|
||||
def failing_read_bytes(self_path, *args, **kwargs):
|
||||
if self_path == config_file:
|
||||
raise PermissionError(13, "Permission denied")
|
||||
return original_read_bytes(self_path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_bytes", failing_read_bytes)
|
||||
|
||||
with pytest.raises(ValidationError, match="cannot be read"):
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# The kept config survives untouched; nothing was rescued half-read.
|
||||
monkeypatch.undo()
|
||||
assert config_file.read_bytes() == kept_bytes
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
|
||||
def test_retry_with_symlinked_live_config_aborts_and_preserves_both(
|
||||
self, extension_dir, project_dir, monkeypatch
|
||||
):
|
||||
@@ -2239,87 +2091,6 @@ class TestExtensionManager:
|
||||
assert (staging_dir / "test-ext-config.yml").read_bytes() == staged_bytes
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
|
||||
def test_retry_with_unreadable_staged_config_aborts_and_preserves_both(
|
||||
self, extension_dir, project_dir, monkeypatch
|
||||
):
|
||||
"""An unreadable staged backup must abort the retry, not crash it.
|
||||
|
||||
Every sibling read in the retry path (the live twin, the packaged
|
||||
baseline check, the mode sidecar) already catches ``OSError``, but the
|
||||
staged file's own ``stat()``/``read_bytes()`` had no boundary, so a
|
||||
staged config that cannot be read crashed the reinstall with a raw
|
||||
``OSError`` instead of the conflict guidance. It must be treated like
|
||||
an uncomparable live config: preserve both copies and abort while
|
||||
dest_dir is untouched.
|
||||
"""
|
||||
manager = ExtensionManager(project_dir)
|
||||
|
||||
packaged_config = extension_dir / "test-ext-config.yml"
|
||||
packaged_config.write_text("model: default-model\n")
|
||||
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
|
||||
config_file = ext_dir / "test-ext-config.yml"
|
||||
config_file.write_text("model: custom-model\nmax_iterations: 99\n")
|
||||
live_bytes = config_file.read_bytes()
|
||||
|
||||
manager.remove("test-ext", keep_config=True)
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
|
||||
staging_dir = manager._rescue_staging_dir("test-ext")
|
||||
|
||||
original_copytree = shutil.copytree
|
||||
copytree_calls = 0
|
||||
|
||||
def flaky_copytree(*args, **kwargs):
|
||||
nonlocal copytree_calls
|
||||
copytree_calls += 1
|
||||
if copytree_calls == 1:
|
||||
dst = args[1]
|
||||
Path(dst).mkdir(parents=True, exist_ok=True)
|
||||
(Path(dst) / "_partial.txt").write_text("partial")
|
||||
raise OSError("simulated disk full")
|
||||
return original_copytree(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_ext_module.shutil, "copytree", flaky_copytree)
|
||||
|
||||
with pytest.raises(OSError, match="simulated disk full"):
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
assert staging_dir.exists()
|
||||
assert (staging_dir / ".rescue-complete").exists()
|
||||
staged_file = staging_dir / "test-ext-config.yml"
|
||||
assert staged_file.is_file()
|
||||
|
||||
# Simulate a staged backup that can no longer be read (e.g. a
|
||||
# permission or I/O error) without touching real permissions so the
|
||||
# test also runs on platforms where chmod is a no-op.
|
||||
original_read_bytes = Path.read_bytes
|
||||
|
||||
def failing_read_bytes(self_path, *args, **kwargs):
|
||||
if self_path == staged_file:
|
||||
raise PermissionError(13, "Permission denied")
|
||||
return original_read_bytes(self_path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_bytes", failing_read_bytes)
|
||||
|
||||
with pytest.raises(ValidationError, match="Preserved extension config conflict"):
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Both copies must survive: the live config and the staged backup.
|
||||
monkeypatch.undo()
|
||||
assert config_file.read_bytes() == live_bytes
|
||||
assert staging_dir.exists()
|
||||
assert staged_file.is_file()
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure_mode",
|
||||
[
|
||||
@@ -3022,30 +2793,6 @@ Real body starts here.
|
||||
assert "Prüfe Konformität" in output
|
||||
assert "\\u" not in output
|
||||
|
||||
def test_render_frontmatter_keeps_long_description_on_one_line(self):
|
||||
"""A long description must not be folded across lines.
|
||||
|
||||
PyYAML wraps plain scalars at ~80 columns by default, which splits a
|
||||
long ``description`` onto a continuation line. The YAML stays valid,
|
||||
but the rendered frontmatter then differs in shape from the
|
||||
hand-written core command templates, where ``description`` is always a
|
||||
single line -- and consumers that read frontmatter line-wise see a
|
||||
truncated description followed by a stray line.
|
||||
"""
|
||||
long_description = (
|
||||
"Execute the implementation plan by processing and executing all "
|
||||
"tasks defined in tasks.md"
|
||||
)
|
||||
frontmatter = {"name": "speckit-implement", "description": long_description}
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
output = registrar.render_frontmatter(frontmatter)
|
||||
|
||||
assert f"description: {long_description}\n" in output
|
||||
|
||||
body = output.split("---\n")[1]
|
||||
assert yaml.safe_load(body)["description"] == long_description
|
||||
|
||||
def test_adjust_script_paths_does_not_mutate_input(self):
|
||||
"""Path adjustments should not mutate caller-owned frontmatter dicts."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"""`specify init` must render user-supplied values literally, not as Rich markup.
|
||||
|
||||
`commands/init.py` interpolated the project name, `--integration`/`--script`
|
||||
values and paths straight into Rich markup f-strings. A name containing a
|
||||
tag-shaped bracket run was therefore consumed as markup:
|
||||
|
||||
* ``specify init "proj [v2]"`` succeeded and created the directory, but the
|
||||
Next Steps panel printed ``cd proj`` -- a command that fails when pasted.
|
||||
* ``specify init "app[/red]x"`` created the directory and then died with
|
||||
``MarkupError``, so the user saw a traceback for a project that had in fact
|
||||
been scaffolded.
|
||||
|
||||
Every sibling CLI module (extensions, presets, workflows, integrations) already
|
||||
escapes user-controlled display values; init.py was the outlier.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.commands.init import _shell_quote_arg
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _strip(text: str) -> str:
|
||||
return _ANSI.sub("", text or "")
|
||||
|
||||
|
||||
def _init(tmp_path: Path, name: str):
|
||||
"""Run a fully offline, non-interactive `specify init <name>`."""
|
||||
previous = os.getcwd()
|
||||
os.chdir(tmp_path)
|
||||
try:
|
||||
return CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
name,
|
||||
"--integration",
|
||||
"generic",
|
||||
"--integration-options",
|
||||
"--commands-dir .agent/commands",
|
||||
"--ignore-agent-tools",
|
||||
"--offline",
|
||||
],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["proj [v2]", "my[bold]app"])
|
||||
def test_next_steps_cd_shows_the_real_project_name(tmp_path: Path, name: str):
|
||||
"""The `cd` line must name the directory that was actually created."""
|
||||
result = _init(tmp_path, name)
|
||||
assert result.exit_code == 0, _strip(result.stdout)
|
||||
assert (tmp_path / name).is_dir()
|
||||
|
||||
out = _strip(result.stdout)
|
||||
cd_lines = [line for line in out.splitlines() if "cd " in line]
|
||||
assert cd_lines, out
|
||||
assert f"cd {_shell_quote_arg(name)}" in " ".join(cd_lines), cd_lines
|
||||
|
||||
|
||||
def test_closing_tag_in_project_name_does_not_crash(tmp_path: Path):
|
||||
"""A name forming a closing tag raised MarkupError *after* the project had
|
||||
been created, so init reported failure for work it had completed."""
|
||||
name = "app[/red]x"
|
||||
result = _init(tmp_path, name)
|
||||
|
||||
assert result.exception is None or not isinstance(
|
||||
result.exception, Exception
|
||||
) or "MarkupError" not in type(result.exception).__name__, (
|
||||
f"unexpected {type(result.exception).__name__}: {result.exception}"
|
||||
)
|
||||
assert result.exit_code == 0, _strip(result.stdout)
|
||||
assert (tmp_path / name).is_dir()
|
||||
assert f"cd {_shell_quote_arg(name)}" in _strip(result.stdout)
|
||||
|
||||
|
||||
def test_invalid_integration_value_is_rendered_literally(tmp_path: Path):
|
||||
"""An invalid `--integration` value is echoed back; it must not be parsed as
|
||||
markup (nor raise) when it contains a bracket run."""
|
||||
previous = os.getcwd()
|
||||
os.chdir(tmp_path)
|
||||
try:
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["init", "proj", "--integration", "nope[/red]", "--ignore-agent-tools"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "nope[/red]" in _strip(result.stdout)
|
||||
|
||||
|
||||
def _cd_argument(stdout: str) -> str:
|
||||
"""Return the argument of the printed `cd` command, verbatim.
|
||||
|
||||
The line is rendered inside a Rich panel, so the trailing box-drawing
|
||||
border and its padding are stripped before the argument is compared.
|
||||
"""
|
||||
marker = "Go to the project folder: cd "
|
||||
for line in _strip(stdout).splitlines():
|
||||
if marker in line:
|
||||
return line.split(marker, 1)[1].rstrip().rstrip("│").rstrip()
|
||||
raise AssertionError(f"no cd line in output:\n{stdout}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["proj v2", "my project"])
|
||||
def test_cd_line_quotes_a_name_containing_whitespace(tmp_path: Path, name: str):
|
||||
"""Rich-escaping alone left `cd proj v2`, which every shell reads as two
|
||||
arguments, so the copy-pasted command did not enter the directory."""
|
||||
result = _init(tmp_path, name)
|
||||
assert result.exit_code == 0, _strip(result.stdout)
|
||||
assert (tmp_path / name).is_dir()
|
||||
|
||||
printed = _cd_argument(result.stdout)
|
||||
assert printed != name, "a whitespace-bearing name must be quoted"
|
||||
assert name in printed, printed
|
||||
assert printed == _shell_quote_arg(name)
|
||||
|
||||
|
||||
def test_ordinary_name_is_not_quoted(tmp_path: Path):
|
||||
"""The common case must stay byte-identical: no gratuitous quoting."""
|
||||
result = _init(tmp_path, "my-project")
|
||||
assert result.exit_code == 0, _strip(result.stdout)
|
||||
assert _cd_argument(result.stdout) == "my-project"
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize("name", ["proj v2", "proj [v2]", "my-project"])
|
||||
def test_printed_cd_command_actually_changes_directory(tmp_path: Path, name: str):
|
||||
"""Execute the printed command rather than only inspecting it.
|
||||
|
||||
This is the assertion the string comparisons cannot make: the rendered
|
||||
`cd <arg>` is fed to a real shell and must land in the created directory.
|
||||
"""
|
||||
result = _init(tmp_path, name)
|
||||
assert result.exit_code == 0, _strip(result.stdout)
|
||||
target = tmp_path / name
|
||||
assert target.is_dir()
|
||||
|
||||
printed = _cd_argument(result.stdout)
|
||||
proc = subprocess.run(
|
||||
["bash", "-c", f"cd {printed} && pwd"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert proc.returncode == 0, f"cd {printed!r} failed: {proc.stderr}"
|
||||
assert Path(proc.stdout.strip()).name == name, proc.stdout
|
||||
|
||||
|
||||
def test_shell_quote_arg_is_host_appropriate():
|
||||
"""The helper follows `_version._render_argv`: list2cmdline on Windows,
|
||||
shlex.quote elsewhere. Names needing no quoting round-trip unchanged."""
|
||||
assert _shell_quote_arg("my-project") == "my-project"
|
||||
quoted = _shell_quote_arg("my project")
|
||||
assert quoted != "my project"
|
||||
if os.name == "nt":
|
||||
assert quoted == '"my project"'
|
||||
else:
|
||||
assert quoted == "'my project'"
|
||||
@@ -406,37 +406,6 @@ provides:
|
||||
with pytest.raises(PresetValidationError, match="Missing requires.speckit_version"):
|
||||
PresetManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
1.0, # unquoted YAML float -- the likeliest authoring slip
|
||||
5, # unquoted int
|
||||
True, # YAML `yes`/`true`
|
||||
None, # `speckit_version:` written but left empty
|
||||
[">=0.1.0"], # iterable: slips past SpecifierSet() entirely
|
||||
{"min": "0.1"}, # iterable: same
|
||||
" ", # blank string must not mean "any version"
|
||||
],
|
||||
)
|
||||
def test_non_string_speckit_version(self, temp_dir, valid_pack_data, bad):
|
||||
"""A non-string requires.speckit_version must be a PresetValidationError.
|
||||
|
||||
It was presence-checked only, so it reached ``SpecifierSet(required)`` in
|
||||
check_compatibility(), which is guarded by ``except InvalidSpecifier``
|
||||
alone. A non-string escapes that guard two ways: scalars raise TypeError
|
||||
from the constructor, and a list/dict is iterable so SpecifierSet accepts
|
||||
it and the failure surfaces later as ``AttributeError: 'str' object has no
|
||||
attribute 'filter'`` from inside .contains().
|
||||
"""
|
||||
valid_pack_data["requires"]["speckit_version"] = bad
|
||||
manifest_path = temp_dir / "preset.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_pack_data, f)
|
||||
with pytest.raises(
|
||||
PresetValidationError, match="Invalid requires.speckit_version"
|
||||
):
|
||||
PresetManifest(manifest_path)
|
||||
|
||||
def test_no_templates_provided(self, temp_dir, valid_pack_data):
|
||||
"""Test pack with no templates."""
|
||||
valid_pack_data["provides"]["templates"] = []
|
||||
@@ -515,27 +484,6 @@ class TestPresetRegistry:
|
||||
assert registry.list() == {}
|
||||
assert not registry.is_installed("test-pack")
|
||||
|
||||
def test_load_starts_fresh_for_non_utf8_registry(self, temp_dir):
|
||||
"""A registry file with undecodable bytes must start fresh, not raise.
|
||||
|
||||
``_load()`` already treats malformed JSON as "corrupted registry,
|
||||
start fresh", but a registry whose *bytes* cannot be decoded as UTF-8
|
||||
raised a raw ``UnicodeDecodeError`` from the same boundary — the same
|
||||
corruption class reaching a different exception type.
|
||||
"""
|
||||
packs_dir = temp_dir / "packs"
|
||||
packs_dir.mkdir()
|
||||
(packs_dir / PresetRegistry.REGISTRY_FILE).write_bytes(
|
||||
b"\xff\xfe not utf-8 \xc3\x28"
|
||||
)
|
||||
|
||||
registry = PresetRegistry(packs_dir)
|
||||
|
||||
assert registry.data == {
|
||||
"schema_version": PresetRegistry.SCHEMA_VERSION,
|
||||
"presets": {},
|
||||
}
|
||||
|
||||
def test_add_and_get(self, temp_dir):
|
||||
"""Test adding and retrieving a pack."""
|
||||
packs_dir = temp_dir / "packs"
|
||||
@@ -1016,26 +964,6 @@ class TestPresetManager:
|
||||
with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"):
|
||||
manager.check_compatibility(manifest, "0.1.5")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[1.0, 5, True, None, [">=0.1.0"], {"min": "0.1"}],
|
||||
)
|
||||
def test_check_compatibility_non_string_specifier(self, pack_dir, temp_dir, bad):
|
||||
"""check_compatibility() must report a non-string as a compatibility error.
|
||||
|
||||
Defense in depth for the validator check: this method is public and the
|
||||
specifier is read back out of mutable manifest data, and ``except
|
||||
InvalidSpecifier`` does not cover a non-string. Without the guard, scalars
|
||||
raise a bare TypeError and iterables construct fine only to break inside
|
||||
.contains() -- neither is a PresetCompatibilityError, so both bypass the
|
||||
CLI's "Compatibility Error" handler and exit 1 with a raw traceback.
|
||||
"""
|
||||
manager = PresetManager(temp_dir)
|
||||
manifest = PresetManifest(pack_dir / "preset.yml")
|
||||
manifest.data["requires"]["speckit_version"] = bad
|
||||
with pytest.raises(PresetCompatibilityError, match="Invalid version specifier"):
|
||||
manager.check_compatibility(manifest, "0.1.5")
|
||||
|
||||
def test_install_with_priority(self, project_dir, pack_dir):
|
||||
"""Test installing a pack with custom priority."""
|
||||
manager = PresetManager(project_dir)
|
||||
@@ -11425,113 +11353,6 @@ class TestResolveContent:
|
||||
content = resolver.resolve_content("nonexistent")
|
||||
assert content is None
|
||||
|
||||
def test_resolve_content_unreadable_winning_layer_returns_none(self, project_dir):
|
||||
"""An undecodable winning layer must yield None, not a raw traceback.
|
||||
|
||||
``collect_all_layers`` deliberately keeps a non-UTF-8 legacy command
|
||||
layer (with its ``replace`` default) so unrelated commands still
|
||||
resolve. ``resolve_content`` then read that same file without a
|
||||
boundary, so the tolerated layer crashed with ``UnicodeDecodeError``
|
||||
at composition time — reachable from ``specify preset add`` via
|
||||
``_register_commands``. The documented contract is "Composed content
|
||||
string, or None if not found".
|
||||
"""
|
||||
presets_dir = project_dir / ".specify" / "presets"
|
||||
command_path = (
|
||||
presets_dir / "legacy-pack" / "commands" / "speckit.legacy.md"
|
||||
)
|
||||
command_path.parent.mkdir(parents=True)
|
||||
command_path.write_bytes(b"\xff\xfe")
|
||||
PresetRegistry(presets_dir).add(
|
||||
"legacy-pack", {"version": "1.0.0", "priority": 10}
|
||||
)
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
content = resolver.resolve_content("speckit.legacy", "command")
|
||||
assert content is None
|
||||
|
||||
def test_resolve_content_unreadable_base_under_composing_layer(
|
||||
self, project_dir, temp_dir, valid_pack_data
|
||||
):
|
||||
"""An undecodable base beneath a valid composing layer yields None.
|
||||
|
||||
Covers the base-read guard: the winning layer composes (append), so
|
||||
resolution reads the base layer beneath it — here the core template,
|
||||
corrupted to non-UTF-8 — and must return None instead of crashing.
|
||||
"""
|
||||
pack_data = {**valid_pack_data}
|
||||
pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"}
|
||||
pack_data["provides"] = {
|
||||
"templates": [{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "templates/spec-template.md",
|
||||
"strategy": "append",
|
||||
}]
|
||||
}
|
||||
pack_dir = temp_dir / "append-pack"
|
||||
pack_dir.mkdir()
|
||||
with open(pack_dir / "preset.yml", 'w') as f:
|
||||
yaml.dump(pack_data, f)
|
||||
(pack_dir / "templates").mkdir()
|
||||
(pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
core_spec = project_dir / ".specify" / "templates" / "spec-template.md"
|
||||
core_spec.write_bytes(b"\xff\xfe")
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
assert resolver.resolve_content("spec-template") is None
|
||||
|
||||
def test_resolve_content_unreadable_composing_layer(
|
||||
self, project_dir, temp_dir, valid_pack_data, monkeypatch
|
||||
):
|
||||
"""An unreadable composing layer over a valid base yields None.
|
||||
|
||||
Covers the composition-loop read and the ``OSError`` half of the
|
||||
boundary: the base (core template) reads fine, but the append layer
|
||||
raises a mocked ``PermissionError`` — mocked so the case also holds
|
||||
under privileged CI where permission bits are not enforced.
|
||||
"""
|
||||
pack_data = {**valid_pack_data}
|
||||
pack_data["preset"] = {**valid_pack_data["preset"], "id": "append-pack", "name": "Append"}
|
||||
pack_data["provides"] = {
|
||||
"templates": [{
|
||||
"type": "template",
|
||||
"name": "spec-template",
|
||||
"file": "templates/spec-template.md",
|
||||
"strategy": "append",
|
||||
}]
|
||||
}
|
||||
pack_dir = temp_dir / "append-pack"
|
||||
pack_dir.mkdir()
|
||||
with open(pack_dir / "preset.yml", 'w') as f:
|
||||
yaml.dump(pack_data, f)
|
||||
(pack_dir / "templates").mkdir()
|
||||
(pack_dir / "templates" / "spec-template.md").write_text("## Appended Section\n")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(pack_dir, "0.1.5")
|
||||
|
||||
layer_path = (
|
||||
project_dir / ".specify" / "presets" / "append-pack"
|
||||
/ "templates" / "spec-template.md"
|
||||
)
|
||||
assert layer_path.is_file()
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def failing_read_text(self_path, *args, **kwargs):
|
||||
if self_path == layer_path:
|
||||
raise PermissionError(13, "Permission denied")
|
||||
return original_read_text(self_path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", failing_read_text)
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
assert resolver.resolve_content("spec-template") is None
|
||||
|
||||
def test_resolve_content_replace_strategy(self, project_dir, temp_dir, valid_pack_data):
|
||||
"""Test resolve_content with default replace strategy."""
|
||||
manager = PresetManager(project_dir)
|
||||
|
||||
@@ -205,28 +205,3 @@ def test_missing_template_error_matches_all_variants(repo: Path) -> None:
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
assert bash.stderr == ps.stderr == py.stderr
|
||||
|
||||
|
||||
def test_python_text_output_survives_a_legacy_stdout_code_page(repo: Path) -> None:
|
||||
"""Text mode must not crash when stdout cannot encode the status glyphs.
|
||||
|
||||
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
|
||||
console — which is every time an agent or a workflow step captures the
|
||||
output. U+2713 is unencodable in cp1252, so printing it raised
|
||||
UnicodeEncodeError and truncated the document listing. The ASCII fallback is
|
||||
the rendering these markers already have in-tree (Test-FileExists in
|
||||
scripts/powershell/common.ps1, and normalize_status_text).
|
||||
"""
|
||||
feature = repo / "specs" / "001-my-feature"
|
||||
(feature / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feature / "contracts").mkdir()
|
||||
|
||||
env = clean_env()
|
||||
env["PYTHONIOENCODING"] = "cp1252"
|
||||
result = run(py_cmd(repo, SCRIPT), repo, env=env)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "UnicodeEncodeError" not in result.stderr
|
||||
for doc in ("research.md", "data-model.md", "contracts/", "quickstart.md"):
|
||||
assert doc in result.stdout, (doc, result.stdout)
|
||||
assert "[OK] research.md" in normalize_status_text(result.stdout), result.stdout
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Tests for the managed ``.specify/.gitignore`` written by shared-infra install.
|
||||
|
||||
The Specify CLI scaffolds a ``.specify/.gitignore`` so machine-local Spec Kit
|
||||
state (the ``feature.json`` current-feature pointer and per-machine extension
|
||||
``local-config.yml`` overrides) stays out of version control while everything
|
||||
else under ``.specify/`` remains shareable. These tests pin that behaviour:
|
||||
the file is created and manifest-tracked, its patterns actually make git ignore
|
||||
the intended paths, user edits are preserved on a plain re-run, and ``--force``
|
||||
restores the managed content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli import _install_shared_infra
|
||||
from specify_cli.shared_infra import SPECIFY_GITIGNORE_CONTENT
|
||||
|
||||
|
||||
def _install(project: Path, **kwargs) -> None:
|
||||
(project / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
_install_shared_infra(project, "sh", **kwargs)
|
||||
|
||||
|
||||
def test_gitignore_is_written_and_tracked(tmp_path: Path) -> None:
|
||||
project = tmp_path / "proj"
|
||||
_install(project)
|
||||
|
||||
gitignore = project / ".specify" / ".gitignore"
|
||||
assert gitignore.is_file()
|
||||
|
||||
content = gitignore.read_text(encoding="utf-8")
|
||||
assert "feature.json" in content
|
||||
assert "extensions/*/local-config.yml" in content
|
||||
|
||||
manifest = json.loads(
|
||||
(project / ".specify" / "integrations" / "speckit.manifest.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert ".specify/.gitignore" in manifest.get("files", {})
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("git") is None, reason="git not available")
|
||||
def test_git_ignores_the_intended_paths(tmp_path: Path) -> None:
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=project, check=True)
|
||||
|
||||
_install(project)
|
||||
|
||||
(project / ".specify" / "feature.json").write_text("{}", encoding="utf-8")
|
||||
ext_local = project / ".specify" / "extensions" / "git" / "local-config.yml"
|
||||
ext_local.parent.mkdir(parents=True, exist_ok=True)
|
||||
ext_local.write_text("x\n", encoding="utf-8")
|
||||
|
||||
for rel in (
|
||||
".specify/feature.json",
|
||||
".specify/extensions/git/local-config.yml",
|
||||
):
|
||||
result = subprocess.run(
|
||||
["git", "check-ignore", rel],
|
||||
cwd=project,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, f"{rel} was not ignored"
|
||||
|
||||
# A shareable file under .specify/ must NOT be ignored.
|
||||
tracked = subprocess.run(
|
||||
["git", "check-ignore", ".specify/memory/constitution.md"],
|
||||
cwd=project,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert tracked.returncode == 1
|
||||
|
||||
|
||||
def test_user_edits_preserved_by_default(tmp_path: Path) -> None:
|
||||
project = tmp_path / "proj"
|
||||
_install(project)
|
||||
|
||||
gitignore = project / ".specify" / ".gitignore"
|
||||
gitignore.write_text("# my customization\n", encoding="utf-8")
|
||||
|
||||
_install(project) # plain re-run must not clobber user edits
|
||||
assert gitignore.read_text(encoding="utf-8") == "# my customization\n"
|
||||
|
||||
|
||||
def test_force_restores_managed_content(tmp_path: Path) -> None:
|
||||
project = tmp_path / "proj"
|
||||
_install(project)
|
||||
|
||||
gitignore = project / ".specify" / ".gitignore"
|
||||
gitignore.write_text("# my customization\n", encoding="utf-8")
|
||||
|
||||
_install(project, force=True)
|
||||
assert gitignore.read_text(encoding="utf-8") == SPECIFY_GITIGNORE_CONTENT
|
||||
@@ -7053,35 +7053,6 @@ class TestRunState:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
RunState.load("nonexistent", project_dir)
|
||||
|
||||
def test_load_rejects_stored_run_id_mismatch(self, project_dir):
|
||||
"""The state payload cannot redirect later writes to another run."""
|
||||
from specify_cli.workflows.engine import RunState
|
||||
|
||||
run_dir = (
|
||||
project_dir
|
||||
/ ".specify"
|
||||
/ "workflows"
|
||||
/ "runs"
|
||||
/ "requested-run"
|
||||
)
|
||||
run_dir.mkdir(parents=True)
|
||||
(run_dir / "state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"run_id": "other-run",
|
||||
"workflow_id": "test-workflow",
|
||||
"status": "created",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="stored run_id 'other-run' does not match requested run_id 'requested-run'",
|
||||
):
|
||||
RunState.load("requested-run", project_dir)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("installed_workflow_id", "installed_registry_root"),
|
||||
[
|
||||
@@ -7332,94 +7303,6 @@ steps:
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["workflow_id"] == "list-test"
|
||||
|
||||
def test_list_skips_malformed_json(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
runs_dir = project_dir / ".specify" / "workflows" / "runs"
|
||||
bad_dir = runs_dir / "bad-run"
|
||||
bad_dir.mkdir(parents=True)
|
||||
(bad_dir / "state.json").write_text("{invalid json", encoding="utf-8")
|
||||
|
||||
engine = WorkflowEngine(project_dir)
|
||||
assert engine.list_runs() == []
|
||||
|
||||
def test_list_skips_unreadable_file(self, project_dir):
|
||||
import sys
|
||||
import subprocess
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
runs_dir = project_dir / ".specify" / "workflows" / "runs"
|
||||
bad_dir = runs_dir / "bad-run"
|
||||
bad_dir.mkdir(parents=True)
|
||||
state_file = bad_dir / "state.json"
|
||||
state_file.write_text('{"run_id": "x"}', encoding="utf-8")
|
||||
|
||||
if sys.platform == "win32":
|
||||
subprocess.run(["attrib", "+R", str(state_file)], check=True)
|
||||
else:
|
||||
state_file.chmod(0o000)
|
||||
|
||||
try:
|
||||
engine = WorkflowEngine(project_dir)
|
||||
if sys.platform == "win32":
|
||||
assert engine.list_runs() == [{"run_id": "x"}]
|
||||
else:
|
||||
assert engine.list_runs() == []
|
||||
finally:
|
||||
if sys.platform == "win32":
|
||||
subprocess.run(["attrib", "-R", str(state_file)], check=True)
|
||||
else:
|
||||
state_file.chmod(0o644)
|
||||
|
||||
def test_list_skips_non_dict_payload(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
runs_dir = project_dir / ".specify" / "workflows" / "runs"
|
||||
bad_dir = runs_dir / "bad-run"
|
||||
bad_dir.mkdir(parents=True)
|
||||
(bad_dir / "state.json").write_text('["not", "a", "dict"]', encoding="utf-8")
|
||||
|
||||
engine = WorkflowEngine(project_dir)
|
||||
assert engine.list_runs() == []
|
||||
|
||||
def test_list_skips_empty_dict_payload(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
runs_dir = project_dir / ".specify" / "workflows" / "runs"
|
||||
bad_dir = runs_dir / "bad-run"
|
||||
bad_dir.mkdir(parents=True)
|
||||
(bad_dir / "state.json").write_text('{}', encoding="utf-8")
|
||||
|
||||
engine = WorkflowEngine(project_dir)
|
||||
assert engine.list_runs() == []
|
||||
|
||||
def test_list_skips_bad_file_with_valid_sibling(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
runs_dir = project_dir / ".specify" / "workflows" / "runs"
|
||||
bad_dir = runs_dir / "bad-run"
|
||||
bad_dir.mkdir(parents=True)
|
||||
(bad_dir / "state.json").write_text("{bad", encoding="utf-8")
|
||||
|
||||
yaml_str = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "good-run"
|
||||
name: "Good Run"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: step-one
|
||||
type: shell
|
||||
run: "echo test"
|
||||
"""
|
||||
definition = WorkflowDefinition.from_string(yaml_str)
|
||||
engine = WorkflowEngine(project_dir)
|
||||
engine.execute(definition)
|
||||
|
||||
runs = engine.list_runs()
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["workflow_id"] == "good-run"
|
||||
|
||||
|
||||
# ===== Workflow Registry Tests =====
|
||||
|
||||
@@ -16705,57 +16588,6 @@ steps:
|
||||
assert "corrupt run state" not in captured.out
|
||||
assert captured.out.strip() == ""
|
||||
|
||||
def test_status_unreadable_run_state_exits_cleanly(
|
||||
self, project_dir, monkeypatch
|
||||
):
|
||||
"""`workflow status <run_id>` gained a ValueError boundary to match
|
||||
`workflow resume`, but not resume's OSError one -- so an unreadable
|
||||
state.json (bad permissions, a directory in its place, an I/O error)
|
||||
still leaked a raw traceback. exists() is True for a directory, so
|
||||
the guard passes and open() raises OSError."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
runs_dir = project_dir / ".specify" / "workflows" / "runs" / "abc123"
|
||||
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||
# A directory where state.json should be: exists() passes, open() fails.
|
||||
(runs_dir / "state.json").mkdir(exist_ok=True)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["workflow", "status", "abc123"])
|
||||
assert result.exit_code != 0
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert "Error" in result.output
|
||||
|
||||
def test_status_json_unreadable_run_state_error_goes_to_stderr(
|
||||
self, project_dir, monkeypatch, capsys
|
||||
):
|
||||
"""The OSError handler must route to stderr under --json too, so the
|
||||
stdout JSON stream stays parseable -- mirroring the sibling
|
||||
FileNotFoundError/ValueError handlers."""
|
||||
import typer
|
||||
from specify_cli.workflows import _commands
|
||||
from specify_cli.workflows.engine import RunState
|
||||
|
||||
(project_dir / ".specify" / "workflows").mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(
|
||||
_commands, "_require_specify_project", lambda: project_dir
|
||||
)
|
||||
|
||||
def _raise_os_error(*args, **kwargs):
|
||||
raise PermissionError(13, "Permission denied")
|
||||
|
||||
monkeypatch.setattr(RunState, "load", _raise_os_error)
|
||||
|
||||
with pytest.raises(typer.Exit) as exc:
|
||||
_commands.workflow_status("some-run", json_output=True)
|
||||
assert exc.value.exit_code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "Permission denied" in captured.err
|
||||
assert "Permission denied" not in captured.out
|
||||
assert captured.out.strip() == ""
|
||||
|
||||
def test_status_no_run_id_list_path_unaffected(self, project_dir, monkeypatch):
|
||||
"""The no-run-id list-all-runs path must remain unaffected by the
|
||||
new single-run ValueError boundary."""
|
||||
|
||||
Reference in New Issue
Block a user