mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 21:16:02 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3194c543b |
11
AGENTS.md
11
AGENTS.md
@@ -423,17 +423,6 @@ When an issue exists, include its number immediately after the prefix — this i
|
||||
|
||||
---
|
||||
|
||||
## Responding to PR Review Comments
|
||||
|
||||
- If you are an agent working on behalf of a human, **disclose your identity in your PR comment** — name the agent (and model, if applicable) and the human you are acting for (e.g., "Posted on behalf of @user by GitHub Copilot (model: <name-if-known>)").
|
||||
- Post **one** top-level summary comment per review round listing what changed and the commit SHA. Do not reply on every individual comment.
|
||||
- Reply inline only when context is needed (disagreement, deferral, non-obvious fix). Keep it to a sentence or two.
|
||||
- **Never click "Resolve conversation"** — that belongs to the reviewer or PR author.
|
||||
- No emoji, no celebratory framing, no checklist mirroring the reviewer's items, no restating what the reviewer wrote.
|
||||
- Re-request review once per round (when all feedback is addressed), not after every intermediate push.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,34 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.9.4] - 2026-06-04
|
||||
|
||||
### Changed
|
||||
|
||||
- feat(workflows): add JSON output for workflow run resume and status (#2814)
|
||||
- Update workflow-preset community catalog to v1.3.2 (#2841)
|
||||
- fix: recover active skills registration for extensions (#2803)
|
||||
- fix(cursor-agent): enable headless CLI dispatch end-to-end (-p --trust --approve-mcps --force + Windows .cmd shim resolution) (#2631)
|
||||
- Update Superpowers Implementation Bridge extension to v1.0.2 (#2852)
|
||||
- docs(agents): add PR review response guidance to AGENTS.md (#2850)
|
||||
- Allow `specify workflow run` to execute YAML files without a project (#2825)
|
||||
- feat(extensions): add --force flag to extension add for overwrite reinstall (#2530)
|
||||
- chore: release 0.9.3, begin 0.9.4.dev0 development (#2836)
|
||||
|
||||
## [0.9.3] - 2026-06-03
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: render script command hints with active agent separator (#2649)
|
||||
- chore(tests): fix ruff lint violations in tests/ (#2827)
|
||||
- fix(workflows): validate run_id in RunState.load before touching the … (#2813)
|
||||
- feat(cli): implement specify self upgrade (#2475)
|
||||
- feat(workflows): allow resume to accept updated workflow inputs (#2815)
|
||||
- catalog: rename "superpowers-bridge" to "superspec" (v1.0.1) (#2772)
|
||||
- fix(cli): force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError (#2817)
|
||||
- fix(plan): clarify quickstart validation guide scope (#2805)
|
||||
- chore: release 0.9.2, begin 0.9.3.dev0 development (#2823)
|
||||
|
||||
## [0.9.2] - 2026-06-02
|
||||
|
||||
### Changed
|
||||
|
||||
22
README.md
22
README.md
@@ -59,24 +59,6 @@ specify init my-project --integration copilot
|
||||
cd my-project
|
||||
```
|
||||
|
||||
To check for updates or upgrade the installed CLI, use the self-management commands. See the [Upgrade Guide](./docs/upgrade.md) for detailed scenarios and customization options.
|
||||
|
||||
```bash
|
||||
# Check whether a newer release is available (read-only — does not modify anything)
|
||||
specify self check
|
||||
|
||||
# Preview what would run, without actually upgrading
|
||||
specify self upgrade --dry-run
|
||||
|
||||
# Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
|
||||
# Or pin a specific release tag (replace vX.Y.Z[suffix] with your desired release tag)
|
||||
specify self upgrade --tag vX.Y.Z[suffix]
|
||||
```
|
||||
|
||||
Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. For `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work, including dev, alpha/beta/rc, or build metadata suffixes. `uvx` (ephemeral) runs and source checkouts are detected and produce path-specific guidance instead of running an installer. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed).
|
||||
|
||||
### 3. Establish project principles
|
||||
|
||||
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
|
||||
@@ -151,7 +133,7 @@ Run `specify integration list` to see all available integrations in your install
|
||||
|
||||
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. For integrations that support skills mode, passing `--integration <agent> --integration-options="--skills"` installs agent skills instead of slash-command prompt files.
|
||||
|
||||
### Core Commands
|
||||
#### Core Commands
|
||||
|
||||
Essential commands for the Spec-Driven Development workflow:
|
||||
|
||||
@@ -164,7 +146,7 @@ Essential commands for the Spec-Driven Development workflow:
|
||||
| `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution |
|
||||
| `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan |
|
||||
|
||||
### Optional Commands
|
||||
#### Optional Commands
|
||||
|
||||
Additional commands for enhanced quality and validation:
|
||||
|
||||
|
||||
@@ -114,8 +114,8 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
|
||||
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
|
||||
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
|
||||
@@ -88,8 +88,6 @@ specify version
|
||||
|
||||
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
|
||||
|
||||
**Stay current:** Run `specify self check` periodically to learn whether a newer release is available — it is read-only and never modifies your installation. When you are ready to upgrade, follow the [Upgrade Guide](./upgrade.md).
|
||||
|
||||
After initialization, you should see the following commands available in your coding agent:
|
||||
|
||||
- `/speckit.specify` - Create specifications
|
||||
|
||||
@@ -11,7 +11,6 @@ specify workflow run <source>
|
||||
| Option | Description |
|
||||
| ------------------- | -------------------------------------------------------- |
|
||||
| `-i` / `--input` | Pass input values as `key=value` (repeatable) |
|
||||
| `--json` | Emit the run outcome as a single JSON object |
|
||||
|
||||
Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via `--input` or will be prompted interactively.
|
||||
|
||||
@@ -21,25 +20,7 @@ Example:
|
||||
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full
|
||||
```
|
||||
|
||||
With `--json`, a single machine-readable object is printed instead of formatted text (the default output is unchanged when the flag is omitted):
|
||||
|
||||
```bash
|
||||
specify workflow run my-pipeline.yml --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "662bf791",
|
||||
"workflow_id": "build-and-review",
|
||||
"status": "paused",
|
||||
"current_step_id": "review",
|
||||
"current_step_index": 0
|
||||
}
|
||||
```
|
||||
|
||||
`workflow_id` is the `workflow.id` declared inside the YAML, not the file name. The object is printed exactly as shown — pretty-printed with two-space indentation, on plain stdout with no Rich markup — so it always parses. While the workflow runs under `--json`, any progress a step would print (for example a gate prompt, or output from a prompt step's CLI subprocess) is redirected to stderr, so stdout carries only the JSON object. Read the object from stdout; leave stderr attached to the terminal or capture it separately.
|
||||
|
||||
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.{yml,yaml}>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
|
||||
> **Note:** All workflow commands require a project already initialized with `specify init`.
|
||||
|
||||
## Resume a Workflow
|
||||
|
||||
@@ -47,29 +28,14 @@ specify workflow run my-pipeline.yml --json
|
||||
specify workflow resume <run_id>
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | -------------------------------------------------------- |
|
||||
| `-i` / `--input` | Updated input values as `key=value` (repeatable) |
|
||||
| `--json` | Emit the resume outcome as a single JSON object |
|
||||
|
||||
Resumes a paused or failed workflow run from the exact step where it stopped. Useful after responding to a gate step or fixing an issue that caused a failure.
|
||||
|
||||
Supplied `--input` values are merged over the run's stored inputs and re-validated against the workflow's input types, then the blocked step is re-run with the updated values. This lets a run continue with information that only became available after it paused, or with a corrected value after a failure:
|
||||
|
||||
```bash
|
||||
specify workflow resume <run_id> --input cmd="exit 0"
|
||||
```
|
||||
|
||||
## Workflow Status
|
||||
|
||||
```bash
|
||||
specify workflow status [<run_id>]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | -------------------------------------------------------- |
|
||||
| `--json` | Emit run status (or the runs list) as a JSON object |
|
||||
|
||||
Shows the status of a specific run, or lists all runs if no ID is given. Run states: `created`, `running`, `completed`, `paused`, `failed`, `aborted`.
|
||||
|
||||
## List Installed Workflows
|
||||
|
||||
@@ -8,10 +8,8 @@
|
||||
|
||||
| What to Upgrade | Command | When to Use |
|
||||
|----------------|---------|-------------|
|
||||
| **CLI Tool (recommended)** | `specify self upgrade` | Latest stable release, in place. Auto-detects whether you installed via `uv tool` or `pipx`. |
|
||||
| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z[suffix]` | Upgrade to a specific release tag instead of the latest stable. Suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms. |
|
||||
| **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. |
|
||||
| **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. |
|
||||
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
|
||||
| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release |
|
||||
| **Project Files** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
|
||||
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
|
||||
|
||||
@@ -21,32 +19,12 @@
|
||||
|
||||
The CLI tool (`specify`) is separate from your project files. Upgrade it to get the latest features and bug fixes.
|
||||
|
||||
### Recommended: `specify self upgrade`
|
||||
|
||||
The CLI ships with two self-management commands that handle the common case automatically:
|
||||
Before upgrading, you can check whether a newer released version is available:
|
||||
|
||||
```bash
|
||||
# Check whether a newer release is available (read-only — does not modify anything)
|
||||
specify self check
|
||||
|
||||
# Preview what would run, without actually upgrading
|
||||
specify self upgrade --dry-run
|
||||
|
||||
# Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
|
||||
# Or pin a specific release tag (replace vX.Y.Z[suffix] with the tag you want)
|
||||
specify self upgrade --tag vX.Y.Z[suffix]
|
||||
```
|
||||
|
||||
Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work. The other paths print path-specific guidance and exit 0 without touching anything.
|
||||
|
||||
Pinned tags must start with `vMAJOR.MINOR.PATCH`. Optional suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms such as `v1.0.0-rc1`, `v0.8.0.dev0`, `v0.8.0+build.42`, or the combination `v1.0.0-rc1+build.42`; branch names, hash refs, `latest`, and bare versions without `v` are rejected.
|
||||
|
||||
Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and reports that it timed out while waiting for the installer subprocess, including the configured timeout and manual retry command. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases.
|
||||
|
||||
If your installed CLI is older than the release that introduced `specify self upgrade`, use the manual equivalents below. These commands are also useful when you want explicit control over the installer command.
|
||||
|
||||
### If you installed with `uv tool install`
|
||||
|
||||
Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag):
|
||||
@@ -76,14 +54,10 @@ pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
### Verify the upgrade
|
||||
|
||||
```bash
|
||||
# Confirms the CLI is working and shows installed tools
|
||||
specify check
|
||||
|
||||
# Confirms the installed version against the latest GitHub release
|
||||
specify self check
|
||||
```
|
||||
|
||||
`specify check` shows the surrounding tool environment; `specify self check` is read-only and tells you whether you're now on the latest release (`Up to date: X.Y.Z`) or if a newer one became available between releases.
|
||||
This shows installed tools and confirms the CLI is working. Use `specify version` to confirm which persistent CLI version is currently on your `PATH`.
|
||||
|
||||
---
|
||||
|
||||
@@ -212,8 +186,8 @@ Restart your IDE to refresh the command list.
|
||||
### Scenario 1: "I just want new slash commands"
|
||||
|
||||
```bash
|
||||
# Upgrade CLI (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
# Upgrade CLI (if using persistent install)
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
|
||||
|
||||
# Update project files to get new commands
|
||||
specify init --here --force --integration copilot
|
||||
@@ -230,7 +204,7 @@ cp .specify/memory/constitution.md /tmp/constitution-backup.md
|
||||
cp -r .specify/templates /tmp/templates-backup
|
||||
|
||||
# 2. Upgrade CLI
|
||||
specify self upgrade
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
|
||||
|
||||
# 3. Update project
|
||||
specify init --here --force --integration copilot
|
||||
@@ -414,19 +388,15 @@ Only Spec Kit infrastructure files:
|
||||
|
||||
### "CLI upgrade doesn't seem to work"
|
||||
|
||||
If a command behaves like an older Spec Kit version, first ask the CLI itself:
|
||||
If a command behaves like an older Spec Kit version, first check for local CLI drift:
|
||||
|
||||
```bash
|
||||
# Read-only — prints "Up to date: X.Y.Z" or "Update available: X.Y.Z → vY.Z.W"
|
||||
specify self check
|
||||
|
||||
# Preview the install method, current version, and target tag the upgrade would use
|
||||
specify self upgrade --dry-run
|
||||
```
|
||||
|
||||
`specify check` is an offline environment scan; `specify self check` is the CLI version lookup.
|
||||
|
||||
If `self check` shows the wrong version, verify the installation:
|
||||
Verify the installation:
|
||||
|
||||
```bash
|
||||
# Check installed tools
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-04T00:00:00Z",
|
||||
"updated_at": "2026-06-02T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -2756,8 +2756,8 @@
|
||||
"id": "speckit-superpowers-bridge",
|
||||
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
|
||||
"author": "lihan3238",
|
||||
"version": "1.0.2",
|
||||
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v1.0.2/speckit-superpowers-bridge-v1.0.2.zip",
|
||||
"version": "0.7.0",
|
||||
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.7.0/speckit-superpowers-bridge-v0.7.0.zip",
|
||||
"repository": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||
"homepage": "https://github.com/lihan3238/speckit-superpowers-bridge",
|
||||
"documentation": "https://github.com/lihan3238/speckit-superpowers-bridge#readme",
|
||||
@@ -2798,7 +2798,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-15T00:00:00Z",
|
||||
"updated_at": "2026-06-04T00:00:00Z"
|
||||
"updated_at": "2026-05-28T00:00:00Z"
|
||||
},
|
||||
"speckit-utils": {
|
||||
"name": "SDD Utilities",
|
||||
@@ -3039,13 +3039,13 @@
|
||||
"created_at": "2026-03-30T00:00:00Z",
|
||||
"updated_at": "2026-05-24T01:07:34Z"
|
||||
},
|
||||
"superspec": {
|
||||
"name": "Superspec",
|
||||
"id": "superspec",
|
||||
"superpowers-bridge": {
|
||||
"name": "Superpowers Bridge",
|
||||
"id": "superpowers-bridge",
|
||||
"description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.",
|
||||
"author": "WangX0111",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.1.zip",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/WangX0111/superspec",
|
||||
"homepage": "https://github.com/WangX0111/superspec",
|
||||
"documentation": "https://github.com/WangX0111/superspec/blob/main/README.md",
|
||||
@@ -3070,7 +3070,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-05-30T00:00:00Z"
|
||||
"updated_at": "2026-04-22T00:00:00Z"
|
||||
},
|
||||
"sync": {
|
||||
"name": "Spec Sync",
|
||||
@@ -3607,4 +3607,4 @@
|
||||
"updated_at": "2026-04-13T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-03T00:00:00Z",
|
||||
"updated_at": "2026-05-31T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -542,7 +542,7 @@
|
||||
],
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
},
|
||||
"toc-navigation": {
|
||||
"name": "Table of Contents Navigation",
|
||||
"id": "toc-navigation",
|
||||
@@ -595,11 +595,11 @@
|
||||
"workflow-preset": {
|
||||
"name": "Workflow Preset",
|
||||
"id": "workflow-preset",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.1",
|
||||
"description": "Behavior-first specification, design artifacts, and agent-native handoff orchestration.",
|
||||
"author": "bigsmartben",
|
||||
"repository": "https://github.com/bigsmartben/spec-kit-workflow-preset",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.2/spec-kit-workflow-preset-v1.3.2.zip",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.1/spec-kit-workflow-preset-v1.3.1.zip",
|
||||
"homepage": "https://github.com/bigsmartben/spec-kit-workflow-preset",
|
||||
"documentation": "https://github.com/bigsmartben/spec-kit-workflow-preset/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
@@ -618,7 +618,7 @@
|
||||
"handoff"
|
||||
],
|
||||
"created_at": "2026-05-27T00:00:00Z",
|
||||
"updated_at": "2026-06-03T00:00:00Z"
|
||||
"updated_at": "2026-05-28T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.9.4"
|
||||
version = "0.9.2"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -117,20 +117,20 @@ check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||
# Validate required directories and files
|
||||
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||
echo "Run $(format_speckit_command specify "$REPO_ROOT") first to create the feature structure." >&2
|
||||
echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
|
||||
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for tasks.md if required
|
||||
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
||||
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run $(format_speckit_command tasks "$REPO_ROOT") first to create the task list." >&2
|
||||
echo "Run __SPECKIT_COMMAND_TASKS__ first to create the task list." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -307,83 +307,6 @@ has_jq() {
|
||||
command -v jq >/dev/null 2>&1
|
||||
}
|
||||
|
||||
get_invoke_separator() {
|
||||
local repo_root="${1:-$(get_repo_root)}"
|
||||
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
||||
printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local integration_json="$repo_root/.specify/integration.json"
|
||||
local separator="."
|
||||
local parsed_with_jq=0
|
||||
|
||||
if [[ -f "$integration_json" ]]; then
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
local jq_separator
|
||||
if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
|
||||
parsed_with_jq=1
|
||||
case "$jq_separator" in
|
||||
"."|"-") separator="$jq_separator" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
|
||||
if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
state = json.load(fh)
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
separator = "."
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
separator = entry["invoke_separator"]
|
||||
print(separator)
|
||||
except Exception:
|
||||
print(".")
|
||||
PY
|
||||
); then
|
||||
case "$separator" in
|
||||
"."|"-") ;;
|
||||
*) separator="." ;;
|
||||
esac
|
||||
else
|
||||
separator="."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
||||
printf '%s\n' "$separator"
|
||||
}
|
||||
|
||||
format_speckit_command() {
|
||||
local command_name="$1"
|
||||
local repo_root="${2:-$(get_repo_root)}"
|
||||
local separator
|
||||
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
||||
separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
||||
else
|
||||
separator=$(get_invoke_separator "$repo_root")
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
||||
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
||||
fi
|
||||
|
||||
command_name="${command_name#/}"
|
||||
command_name="${command_name#speckit.}"
|
||||
command_name="${command_name#speckit-}"
|
||||
command_name="${command_name//./$separator}"
|
||||
|
||||
printf '/speckit%s%s\n' "$separator" "$command_name"
|
||||
}
|
||||
|
||||
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||||
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
||||
json_escape() {
|
||||
|
||||
@@ -35,13 +35,13 @@ fi
|
||||
|
||||
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run $(format_speckit_command plan "$REPO_ROOT") first to create the implementation plan." >&2
|
||||
echo "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||
echo "Run $(format_speckit_command specify "$REPO_ROOT") first to create the feature structure." >&2
|
||||
echo "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -89,23 +89,20 @@ if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit:$paths.HAS_GI
|
||||
# Validate required directories and files
|
||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
|
||||
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
|
||||
Write-Output "Run $specifyCommand first to create the feature structure."
|
||||
Write-Output "Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
|
||||
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
||||
Write-Output "Run $planCommand first to create the implementation plan."
|
||||
Write-Output "Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check for tasks.md if required
|
||||
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
|
||||
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
|
||||
$tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT
|
||||
Write-Output "Run $tasksCommand first to create the task list."
|
||||
Write-Output "Run __SPECKIT_COMMAND_TASKS__ first to create the task list."
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -355,58 +355,6 @@ function Test-DirHasFiles {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-InvokeSeparator {
|
||||
param([string]$RepoRoot = (Get-RepoRoot))
|
||||
|
||||
if ($null -eq $script:SpecKitInvokeSeparatorCache) {
|
||||
$script:SpecKitInvokeSeparatorCache = @{}
|
||||
}
|
||||
if ($script:SpecKitInvokeSeparatorCache.ContainsKey($RepoRoot)) {
|
||||
return $script:SpecKitInvokeSeparatorCache[$RepoRoot]
|
||||
}
|
||||
|
||||
$separator = '.'
|
||||
$integrationJson = Join-Path $RepoRoot '.specify/integration.json'
|
||||
if (Test-Path -LiteralPath $integrationJson -PathType Leaf) {
|
||||
try {
|
||||
$state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json
|
||||
$key = if ($state.default_integration) { [string]$state.default_integration } elseif ($state.integration) { [string]$state.integration } else { '' }
|
||||
if ($key -and $state.integration_settings) {
|
||||
$settingProperty = $state.integration_settings.PSObject.Properties[$key]
|
||||
if ($settingProperty) {
|
||||
$setting = $settingProperty.Value
|
||||
if ($setting -and ($setting.invoke_separator -eq '.' -or $setting.invoke_separator -eq '-')) {
|
||||
$separator = [string]$setting.invoke_separator
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$separator = '.'
|
||||
}
|
||||
}
|
||||
|
||||
$script:SpecKitInvokeSeparatorCache[$RepoRoot] = $separator
|
||||
return $separator
|
||||
}
|
||||
|
||||
function Format-SpecKitCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$CommandName,
|
||||
[string]$RepoRoot = (Get-RepoRoot)
|
||||
)
|
||||
|
||||
$separator = Get-InvokeSeparator -RepoRoot $RepoRoot
|
||||
$name = $CommandName.TrimStart('/')
|
||||
if ($name.StartsWith('speckit.')) {
|
||||
$name = $name.Substring(8)
|
||||
} elseif ($name.StartsWith('speckit-')) {
|
||||
$name = $name.Substring(8)
|
||||
}
|
||||
$name = $name -replace '\.', $separator
|
||||
|
||||
return "/speckit$separator$name"
|
||||
}
|
||||
|
||||
# Find a usable Python 3 executable (python3, python, or py -3).
|
||||
# Returns the command/arguments as an array, or $null if none found.
|
||||
function Get-Python3Command {
|
||||
|
||||
@@ -28,15 +28,13 @@ if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFe
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
|
||||
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
|
||||
[Console]::Error.WriteLine("Run __SPECKIT_COMMAND_PLAN__ first to create the implementation plan.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
|
||||
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
|
||||
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
|
||||
[Console]::Error.WriteLine("Run __SPECKIT_COMMAND_SPECIFY__ first to create the feature structure.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ Or install globally:
|
||||
specify init --here
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
@@ -87,12 +86,6 @@ from ._agent_config import (
|
||||
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
from ._init_options import (
|
||||
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
|
||||
is_ai_skills_enabled as _is_ai_skills_enabled,
|
||||
load_init_options as load_init_options,
|
||||
save_init_options as save_init_options,
|
||||
)
|
||||
|
||||
app = typer.Typer(
|
||||
name="specify",
|
||||
@@ -266,6 +259,65 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
|
||||
for f in failures:
|
||||
console.print(f" - {f}")
|
||||
|
||||
INIT_OPTIONS_FILE = ".specify/init-options.json"
|
||||
|
||||
|
||||
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
|
||||
"""Persist the CLI options used during ``specify init``.
|
||||
|
||||
Writes a small JSON file to ``.specify/init-options.json`` so that
|
||||
later operations (e.g. preset install) can adapt their behaviour
|
||||
without scanning the filesystem.
|
||||
"""
|
||||
dest = project_path / INIT_OPTIONS_FILE
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Write JSON as real UTF-8 instead of ``\uXXXX`` escape sequences
|
||||
# (``ensure_ascii=False``) and pin the file encoding to match.
|
||||
#
|
||||
# The default ``json.dumps`` output is ASCII-only — any non-ASCII
|
||||
# character is encoded as a ``\uXXXX`` escape — so without the
|
||||
# ``ensure_ascii=False`` flip below the encoding pin alone would be
|
||||
# a no-op for any payload we plausibly write today. We pair the two
|
||||
# so the on-disk bytes match a human's expectation of "this file is
|
||||
# UTF-8" (greppable, readable in editors that don't decode JSON
|
||||
# escapes, friendly to peers running ``cat`` or ``Get-Content``) and
|
||||
# so the encoding pin is a real contract instead of a future hedge.
|
||||
#
|
||||
# ``Path.write_text`` without ``encoding=`` falls back to the system
|
||||
# locale codec (cp1252 / gb2312 / cp932 on Windows), which would
|
||||
# mis-encode non-ASCII bytes locally and produce a file a peer with
|
||||
# a different locale couldn't decode. The sibling integration-
|
||||
# catalog writer in ``integrations/catalog.py`` pins
|
||||
# ``encoding="utf-8"`` for the same reason.
|
||||
dest.write_text(
|
||||
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def load_init_options(project_path: Path) -> dict[str, Any]:
|
||||
"""Load the init options previously saved by ``specify init``.
|
||||
|
||||
Returns an empty dict if the file does not exist or cannot be parsed.
|
||||
"""
|
||||
path = project_path / INIT_OPTIONS_FILE
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
# Match the explicit UTF-8 used by ``save_init_options``; without
|
||||
# it ``read_text`` falls back to the system codec on Windows and
|
||||
# raises ``UnicodeDecodeError`` on any file containing the
|
||||
# multi-byte UTF-8 sequences ``save_init_options`` now writes
|
||||
# directly. ``UnicodeDecodeError`` is a subclass of
|
||||
# ``ValueError``, not ``OSError`` / ``json.JSONDecodeError``, so
|
||||
# it must be listed explicitly here to preserve the existing
|
||||
# "fall back to empty dict" contract for corrupted / foreign-
|
||||
# codec files.
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-context extension config helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -349,10 +401,10 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
|
||||
"""Return the active skills directory, creating it on demand when enabled.
|
||||
|
||||
Reads ``.specify/init-options.json`` to determine whether skills are
|
||||
enabled and which agent was selected. Only ``ai_skills`` set to boolean
|
||||
``True`` creates the directory safely (symlink/containment checks); when
|
||||
``ai_skills`` is not boolean ``True``, only Kimi's native-skills fallback
|
||||
is honoured, and the native skills directory must already exist.
|
||||
enabled and which agent was selected. When ``ai_skills`` is true the
|
||||
directory is created safely (symlink/containment checks); when false
|
||||
only Kimi's native-skills fallback is honoured (directory must already
|
||||
exist).
|
||||
|
||||
Returns:
|
||||
The skills directory ``Path``, or ``None`` if skills are not active.
|
||||
@@ -373,15 +425,14 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
|
||||
if not isinstance(agent, str) or not agent:
|
||||
return None
|
||||
|
||||
ai_skills_enabled = _is_ai_skills_enabled(opts)
|
||||
ai_skills_enabled = bool(opts.get("ai_skills"))
|
||||
if not ai_skills_enabled and agent != "kimi":
|
||||
return None
|
||||
|
||||
skills_dir = _get_skills_dir(project_root, agent)
|
||||
|
||||
if not ai_skills_enabled:
|
||||
# Kimi native-skills fallback when ai_skills is not boolean True:
|
||||
# use the native skills directory only if it already exists.
|
||||
# Kimi native-skills fallback: use the directory only if it exists.
|
||||
if not skills_dir.is_dir():
|
||||
return None
|
||||
_ensure_safe_shared_directory(
|
||||
@@ -390,7 +441,7 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
|
||||
)
|
||||
return skills_dir
|
||||
|
||||
# ai_skills is boolean True: create the directory safely.
|
||||
# ai_skills is explicitly enabled — create the directory safely.
|
||||
_ensure_safe_shared_directory(
|
||||
project_root, skills_dir, context="agent skills directory",
|
||||
)
|
||||
@@ -1560,7 +1611,6 @@ def extension_add(
|
||||
extension: str = typer.Argument(help="Extension name or path"),
|
||||
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
|
||||
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
|
||||
force: bool = typer.Option(False, "--force", help="Overwrite if already installed"),
|
||||
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
||||
):
|
||||
"""Install an extension."""
|
||||
@@ -1575,9 +1625,6 @@ def extension_add(
|
||||
manager = ExtensionManager(project_root)
|
||||
speckit_version = get_speckit_version()
|
||||
|
||||
if force:
|
||||
console.print("[yellow]--force:[/yellow] Will overwrite if already installed")
|
||||
|
||||
# Prompt for URL-based installs BEFORE the spinner so the user can
|
||||
# actually see and respond to the confirmation (the Rich status
|
||||
# spinner overwrites the typer.confirm prompt line, making it appear
|
||||
@@ -1628,15 +1675,11 @@ def extension_add(
|
||||
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if force:
|
||||
console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...")
|
||||
|
||||
manifest = manager.install_from_directory(
|
||||
source_path,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
link_commands=True,
|
||||
force=force
|
||||
)
|
||||
|
||||
elif from_url:
|
||||
@@ -1658,7 +1701,7 @@ def extension_add(
|
||||
zip_path.write_bytes(zip_data)
|
||||
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}")
|
||||
raise typer.Exit(1)
|
||||
@@ -1671,9 +1714,7 @@ def extension_add(
|
||||
# Try bundled extensions first (shipped with spec-kit)
|
||||
bundled_path = _locate_bundled_extension(extension)
|
||||
if bundled_path is not None:
|
||||
manifest = manager.install_from_directory(
|
||||
bundled_path, speckit_version, priority=priority, force=force
|
||||
)
|
||||
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
|
||||
else:
|
||||
# Install from catalog (also resolves display names to IDs)
|
||||
catalog = ExtensionCatalog(project_root)
|
||||
@@ -1694,9 +1735,7 @@ def extension_add(
|
||||
if resolved_id != extension:
|
||||
bundled_path = _locate_bundled_extension(resolved_id)
|
||||
if bundled_path is not None:
|
||||
manifest = manager.install_from_directory(
|
||||
bundled_path, speckit_version, priority=priority, force=force
|
||||
)
|
||||
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
|
||||
|
||||
if bundled_path is None:
|
||||
# Bundled extensions without a download URL must come from the local package
|
||||
@@ -1732,7 +1771,7 @@ def extension_add(
|
||||
|
||||
try:
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
||||
finally:
|
||||
# Clean up downloaded ZIP
|
||||
if zip_path.exists():
|
||||
@@ -2678,111 +2717,22 @@ workflow_catalog_app = typer.Typer(
|
||||
workflow_app.add_typer(workflow_catalog_app, name="catalog")
|
||||
|
||||
|
||||
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
|
||||
"""Parse repeated ``key=value`` CLI inputs into a dict.
|
||||
|
||||
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
|
||||
on any entry missing ``=``.
|
||||
"""
|
||||
inputs: dict[str, Any] = {}
|
||||
for kv in input_values or []:
|
||||
if "=" not in kv:
|
||||
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
|
||||
raise typer.Exit(1)
|
||||
key, _, value = kv.partition("=")
|
||||
inputs[key.strip()] = value.strip()
|
||||
return inputs
|
||||
|
||||
|
||||
def _workflow_run_payload(state: Any) -> dict[str, Any]:
|
||||
"""Machine-readable summary of a run/resume outcome."""
|
||||
return {
|
||||
"run_id": state.run_id,
|
||||
"workflow_id": state.workflow_id,
|
||||
"status": state.status.value,
|
||||
"current_step_id": state.current_step_id,
|
||||
"current_step_index": state.current_step_index,
|
||||
}
|
||||
|
||||
|
||||
def _emit_workflow_json(payload: dict[str, Any]) -> None:
|
||||
"""Write a workflow payload as machine-readable JSON to stdout.
|
||||
|
||||
Uses the builtin ``print`` rather than ``console.print`` so Rich
|
||||
markup interpretation, syntax highlighting, and line-wrapping can
|
||||
never alter the emitted JSON.
|
||||
"""
|
||||
print(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _stdout_to_stderr_when(active: bool):
|
||||
"""Redirect everything written to stdout onto stderr while *active*.
|
||||
|
||||
Suppressing the banner and the step-start callback is not enough to
|
||||
keep a ``--json`` stream clean: individual steps may still write to
|
||||
stdout while the engine runs — the gate step prints its prompt,
|
||||
and the prompt step runs a subprocess that inherits the process's
|
||||
stdout file descriptor. Either would corrupt the single JSON object.
|
||||
|
||||
Redirecting at the file-descriptor level (``dup2``) captures both
|
||||
Python-level writes and inherited-fd subprocess output, so step
|
||||
progress lands on stderr (still visible to a human) while stdout
|
||||
carries only the emitted JSON. A no-op when *active* is false.
|
||||
"""
|
||||
if not active:
|
||||
yield
|
||||
return
|
||||
sys.stdout.flush()
|
||||
saved_stdout_fd = os.dup(1)
|
||||
try:
|
||||
os.dup2(2, 1) # fd 1 (stdout) now points at fd 2 (stderr)
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
yield
|
||||
finally:
|
||||
sys.stdout.flush()
|
||||
os.dup2(saved_stdout_fd, 1) # restore the real stdout
|
||||
os.close(saved_stdout_fd)
|
||||
|
||||
|
||||
@workflow_app.command("run")
|
||||
def workflow_run(
|
||||
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
|
||||
input_values: list[str] | None = typer.Option(
|
||||
None, "--input", "-i", help="Input values as key=value pairs"
|
||||
),
|
||||
json_output: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
help="Emit the run outcome as a single JSON object instead of formatted text.",
|
||||
),
|
||||
):
|
||||
"""Run a workflow from an installed ID or local YAML path."""
|
||||
from .workflows.engine import WorkflowEngine
|
||||
|
||||
source_path = Path(source).expanduser()
|
||||
is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file()
|
||||
|
||||
if is_file_source:
|
||||
# When running a YAML file directly, use cwd as project root
|
||||
# without requiring a .specify/ project directory.
|
||||
project_root = Path.cwd()
|
||||
specify_dir = project_root / ".specify"
|
||||
if specify_dir.is_symlink():
|
||||
console.print("[red]Error:[/red] Refusing to use symlinked .specify path in current directory")
|
||||
raise typer.Exit(1)
|
||||
if specify_dir.exists() and not specify_dir.is_dir():
|
||||
console.print("[red]Error:[/red] .specify path exists but is not a directory")
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
project_root = _require_specify_project()
|
||||
|
||||
project_root = _require_specify_project()
|
||||
engine = WorkflowEngine(project_root)
|
||||
if not json_output:
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
try:
|
||||
definition = engine.load_workflow(source_path if is_file_source else source)
|
||||
definition = engine.load_workflow(source)
|
||||
except FileNotFoundError:
|
||||
console.print(f"[red]Error:[/red] Workflow not found: {source}")
|
||||
raise typer.Exit(1)
|
||||
@@ -2799,15 +2749,20 @@ def workflow_run(
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Parse inputs
|
||||
inputs = _parse_input_values(input_values)
|
||||
inputs: dict[str, Any] = {}
|
||||
if input_values:
|
||||
for kv in input_values:
|
||||
if "=" not in kv:
|
||||
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
|
||||
raise typer.Exit(1)
|
||||
key, _, value = kv.partition("=")
|
||||
inputs[key.strip()] = value.strip()
|
||||
|
||||
if not json_output:
|
||||
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
|
||||
console.print(f"[dim]Version: {definition.version}[/dim]\n")
|
||||
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
|
||||
console.print(f"[dim]Version: {definition.version}[/dim]\n")
|
||||
|
||||
try:
|
||||
with _stdout_to_stderr_when(json_output):
|
||||
state = engine.execute(definition, inputs)
|
||||
state = engine.execute(definition, inputs)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
@@ -2815,10 +2770,6 @@ def workflow_run(
|
||||
console.print(f"[red]Workflow failed:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if json_output:
|
||||
_emit_workflow_json(_workflow_run_payload(state))
|
||||
return
|
||||
|
||||
status_colors = {
|
||||
"completed": "green",
|
||||
"paused": "yellow",
|
||||
@@ -2836,28 +2787,16 @@ def workflow_run(
|
||||
@workflow_app.command("resume")
|
||||
def workflow_resume(
|
||||
run_id: str = typer.Argument(..., help="Run ID to resume"),
|
||||
input_values: list[str] | None = typer.Option(
|
||||
None, "--input", "-i", help="Updated input values as key=value pairs"
|
||||
),
|
||||
json_output: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
help="Emit the resume outcome as a single JSON object instead of formatted text.",
|
||||
),
|
||||
):
|
||||
"""Resume a paused or failed workflow run."""
|
||||
from .workflows.engine import WorkflowEngine
|
||||
|
||||
project_root = _require_specify_project()
|
||||
engine = WorkflowEngine(project_root)
|
||||
if not json_output:
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
inputs = _parse_input_values(input_values)
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
try:
|
||||
with _stdout_to_stderr_when(json_output):
|
||||
state = engine.resume(run_id, inputs or None)
|
||||
state = engine.resume(run_id)
|
||||
except FileNotFoundError:
|
||||
console.print(f"[red]Error:[/red] Run not found: {run_id}")
|
||||
raise typer.Exit(1)
|
||||
@@ -2868,10 +2807,6 @@ def workflow_resume(
|
||||
console.print(f"[red]Resume failed:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if json_output:
|
||||
_emit_workflow_json(_workflow_run_payload(state))
|
||||
return
|
||||
|
||||
status_colors = {
|
||||
"completed": "green",
|
||||
"paused": "yellow",
|
||||
@@ -2885,11 +2820,6 @@ def workflow_resume(
|
||||
@workflow_app.command("status")
|
||||
def workflow_status(
|
||||
run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
|
||||
json_output: bool = typer.Option(
|
||||
False,
|
||||
"--json",
|
||||
help="Emit run status as a single JSON object instead of formatted text.",
|
||||
),
|
||||
):
|
||||
"""Show workflow run status."""
|
||||
from .workflows.engine import WorkflowEngine
|
||||
@@ -2905,21 +2835,6 @@ def workflow_status(
|
||||
console.print(f"[red]Error:[/red] Run not found: {run_id}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if json_output:
|
||||
# Build on the shared run/resume payload so the common fields
|
||||
# (including current_step_index) stay identical across commands.
|
||||
payload = {
|
||||
**_workflow_run_payload(state),
|
||||
"created_at": state.created_at,
|
||||
"updated_at": state.updated_at,
|
||||
"steps": {
|
||||
sid: sd.get("status", "unknown")
|
||||
for sid, sd in state.step_results.items()
|
||||
},
|
||||
}
|
||||
_emit_workflow_json(payload)
|
||||
return
|
||||
|
||||
status_colors = {
|
||||
"completed": "green",
|
||||
"paused": "yellow",
|
||||
@@ -2947,22 +2862,6 @@ def workflow_status(
|
||||
console.print(f" [{sc}]●[/{sc}] {step_id}: {s}")
|
||||
else:
|
||||
runs = engine.list_runs()
|
||||
|
||||
if json_output:
|
||||
payload = {
|
||||
"runs": [
|
||||
{
|
||||
"run_id": r["run_id"],
|
||||
"workflow_id": r.get("workflow_id"),
|
||||
"status": r.get("status", "unknown"),
|
||||
"updated_at": r.get("updated_at"),
|
||||
}
|
||||
for r in runs
|
||||
]
|
||||
}
|
||||
_emit_workflow_json(payload)
|
||||
return
|
||||
|
||||
if not runs:
|
||||
console.print("[yellow]No workflow runs found.[/yellow]")
|
||||
return
|
||||
@@ -3424,17 +3323,6 @@ def workflow_catalog_remove(
|
||||
|
||||
|
||||
def main():
|
||||
# On Windows the default stdout/stderr code page (e.g. cp1252) cannot encode
|
||||
# the Rich banner and box-drawing glyphs, so the CLI crashes with
|
||||
# UnicodeEncodeError whenever output is not a UTF-8 TTY (piped, redirected to
|
||||
# a file, or running under a legacy code page). Force UTF-8 with graceful
|
||||
# replacement so output degrades instead of aborting. No-op on POSIX.
|
||||
if sys.platform == "win32":
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError, OSError):
|
||||
pass
|
||||
app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Helpers for interpreting persisted init options."""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
INIT_OPTIONS_FILE = ".specify/init-options.json"
|
||||
|
||||
|
||||
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
|
||||
"""Persist the CLI options used during ``specify init``."""
|
||||
dest = project_path / INIT_OPTIONS_FILE
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(
|
||||
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def load_init_options(project_path: Path) -> dict[str, Any]:
|
||||
"""Load persisted init options, returning an empty dict when unavailable."""
|
||||
path = project_path / INIT_OPTIONS_FILE
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, UnicodeError):
|
||||
return {}
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
|
||||
"""Return True only when init options explicitly enable AI skills."""
|
||||
return isinstance(opts, Mapping) and opts.get("ai_skills") is True
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,6 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from ._init_options import is_ai_skills_enabled, load_init_options
|
||||
|
||||
|
||||
def _build_agent_configs() -> dict[str, Any]:
|
||||
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
|
||||
@@ -361,6 +359,11 @@ class CommandRegistrar:
|
||||
agent_name: str, frontmatter: dict, body: str, project_root: Path
|
||||
) -> str:
|
||||
"""Resolve script placeholders for skills-backed agents."""
|
||||
try:
|
||||
from . import load_init_options
|
||||
except ImportError:
|
||||
return body
|
||||
|
||||
if not isinstance(frontmatter, dict):
|
||||
frontmatter = {}
|
||||
|
||||
@@ -471,29 +474,6 @@ class CommandRegistrar:
|
||||
return False
|
||||
return os.path.normpath(name) == name
|
||||
|
||||
@staticmethod
|
||||
def _same_lexical_path(left: Path, right: Path) -> bool:
|
||||
"""Compare paths after lexical normalization without resolving symlinks."""
|
||||
return os.path.normcase(os.path.normpath(os.fspath(left))) == os.path.normcase(
|
||||
os.path.normpath(os.fspath(right))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _active_skills_agent(project_root: Path) -> Optional[str]:
|
||||
"""Return the initialized skills-backed agent, if skills mode is active."""
|
||||
opts = load_init_options(project_root)
|
||||
if not isinstance(opts, dict):
|
||||
return None
|
||||
|
||||
agent = opts.get("ai")
|
||||
if not isinstance(agent, str) or not agent:
|
||||
return None
|
||||
# Kimi is a native skills integration; when ai_skills is not boolean
|
||||
# True, Kimi still uses its existing SKILL.md layout.
|
||||
if not is_ai_skills_enabled(opts) and agent != "kimi":
|
||||
return None
|
||||
return agent
|
||||
|
||||
def register_commands(
|
||||
self,
|
||||
agent_name: str,
|
||||
@@ -826,7 +806,6 @@ class CommandRegistrar:
|
||||
project_root: Path,
|
||||
context_note: str = None,
|
||||
link_outputs: bool = False,
|
||||
create_missing_active_skills_dir: bool = False,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all detected agents in the project.
|
||||
|
||||
@@ -838,11 +817,6 @@ class CommandRegistrar:
|
||||
context_note: Custom context comment for markdown output
|
||||
link_outputs: If True, create dev-mode symlinks for rendered
|
||||
command files when supported by the OS.
|
||||
create_missing_active_skills_dir: If True, attempt missing-dir
|
||||
recovery only for the active initialized skills-backed agent.
|
||||
Recovery requires active skills mode (or Kimi's existing native
|
||||
skills directory) and is skipped when safe resolution or
|
||||
creation fails.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
@@ -850,17 +824,7 @@ class CommandRegistrar:
|
||||
results = {}
|
||||
|
||||
self._ensure_configs()
|
||||
active_skills_agent = (
|
||||
self._active_skills_agent(project_root)
|
||||
if create_missing_active_skills_dir else None
|
||||
)
|
||||
active_created_skills_dir: Optional[Path] = None
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
active_skills_output = (
|
||||
agent_name == active_skills_agent
|
||||
and agent_config.get("extension") == "/SKILL.md"
|
||||
)
|
||||
recovered_active_skills_dir: Optional[Path] = None
|
||||
# Check detect_dir first (project-local marker) if configured,
|
||||
# falling back to the resolved dir for output. This prevents
|
||||
# global dirs (e.g. ~/.hermes/skills) from causing false
|
||||
@@ -868,55 +832,13 @@ class CommandRegistrar:
|
||||
detect_dir_str = agent_config.get("detect_dir")
|
||||
if detect_dir_str:
|
||||
detect_path = project_root / detect_dir_str
|
||||
if not detect_path.is_dir():
|
||||
if not active_skills_output:
|
||||
continue
|
||||
try:
|
||||
from . import resolve_active_skills_dir
|
||||
|
||||
recovered_active_skills_dir = (
|
||||
resolve_active_skills_dir(project_root)
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if recovered_active_skills_dir is None or not detect_path.is_dir():
|
||||
continue
|
||||
active_created_skills_dir = recovered_active_skills_dir
|
||||
if not detect_path.exists():
|
||||
continue
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
|
||||
agent_dir_existed = agent_dir.is_dir()
|
||||
register_missing_active_skills_agent = (
|
||||
not agent_dir_existed
|
||||
and active_skills_output
|
||||
)
|
||||
if register_missing_active_skills_agent:
|
||||
if recovered_active_skills_dir is None:
|
||||
try:
|
||||
from . import resolve_active_skills_dir
|
||||
|
||||
recovered_active_skills_dir = (
|
||||
resolve_active_skills_dir(project_root)
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if recovered_active_skills_dir is None:
|
||||
continue
|
||||
active_created_skills_dir = recovered_active_skills_dir
|
||||
# Shared skill dirs such as .agents/skills should not make
|
||||
# later integrations look detected when the active agent just
|
||||
# recreated the directory during this registration pass.
|
||||
created_by_active_agent = (
|
||||
active_created_skills_dir is not None
|
||||
and self._same_lexical_path(agent_dir, active_created_skills_dir)
|
||||
and agent_name != active_skills_agent
|
||||
)
|
||||
should_register = (
|
||||
agent_dir_existed and not created_by_active_agent
|
||||
) or register_missing_active_skills_agent
|
||||
|
||||
if should_register:
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name,
|
||||
@@ -930,16 +852,8 @@ class CommandRegistrar:
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
if register_missing_active_skills_agent:
|
||||
active_created_skills_dir = (
|
||||
recovered_active_skills_dir or agent_dir
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
except OSError:
|
||||
if register_missing_active_skills_agent:
|
||||
continue
|
||||
raise
|
||||
|
||||
return results
|
||||
|
||||
@@ -978,12 +892,12 @@ class CommandRegistrar:
|
||||
detect_dir_str = agent_config.get("detect_dir")
|
||||
if detect_dir_str:
|
||||
detect_path = project_root / detect_dir_str
|
||||
if not detect_path.is_dir():
|
||||
if not detect_path.exists():
|
||||
continue
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
if agent_dir.is_dir():
|
||||
if agent_dir.exists():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name,
|
||||
|
||||
@@ -26,15 +26,14 @@ from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
|
||||
from ._init_options import is_ai_skills_enabled
|
||||
|
||||
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
@@ -831,53 +830,15 @@ class ExtensionManager:
|
||||
be created due to symlink, containment, or permission issues so
|
||||
that callers can fall back gracefully.
|
||||
"""
|
||||
from . import (
|
||||
_print_cli_warning,
|
||||
load_init_options,
|
||||
resolve_active_skills_dir,
|
||||
)
|
||||
|
||||
def _ensure_usable(skills_dir: Path) -> Optional[Path]:
|
||||
try:
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
if not skills_dir.is_dir():
|
||||
raise NotADirectoryError(f"{skills_dir} is not a directory")
|
||||
except (OSError, ValueError) as exc:
|
||||
_print_cli_warning(
|
||||
"resolve", "skills directory", str(skills_dir), exc,
|
||||
continuing="Continuing without skill registration.",
|
||||
)
|
||||
return None
|
||||
return skills_dir
|
||||
|
||||
from . import resolve_active_skills_dir, _print_cli_warning
|
||||
try:
|
||||
skills_dir = resolve_active_skills_dir(self.project_root)
|
||||
return resolve_active_skills_dir(self.project_root)
|
||||
except (ValueError, OSError) as exc:
|
||||
_print_cli_warning(
|
||||
"resolve", "skills directory", None, exc,
|
||||
continuing="Continuing without skill registration.",
|
||||
)
|
||||
return None
|
||||
if skills_dir is None:
|
||||
return None
|
||||
|
||||
opts = load_init_options(self.project_root)
|
||||
if not isinstance(opts, dict):
|
||||
return _ensure_usable(skills_dir)
|
||||
selected_ai = opts.get("ai")
|
||||
if not isinstance(selected_ai, str) or not selected_ai:
|
||||
return _ensure_usable(skills_dir)
|
||||
|
||||
from .agents import CommandRegistrar
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai)
|
||||
if agent_config and agent_config.get("extension") == "/SKILL.md":
|
||||
agent_skills_dir = registrar._resolve_agent_dir(
|
||||
selected_ai, agent_config, self.project_root
|
||||
)
|
||||
return _ensure_usable(agent_skills_dir)
|
||||
return _ensure_usable(skills_dir)
|
||||
|
||||
def _register_extension_skills(
|
||||
self,
|
||||
@@ -1212,7 +1173,6 @@ class ExtensionManager:
|
||||
register_commands: bool = True,
|
||||
priority: int = 10,
|
||||
link_commands: bool = False,
|
||||
force: bool = False,
|
||||
) -> ExtensionManifest:
|
||||
"""Install extension from a local directory.
|
||||
|
||||
@@ -1223,8 +1183,6 @@ class ExtensionManager:
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
link_commands: If True, register rendered agent artifacts as
|
||||
symlinks to a dev cache when supported by the OS.
|
||||
force: If True and extension is already installed, remove it first
|
||||
before proceeding with installation
|
||||
|
||||
Returns:
|
||||
Installed extension manifest
|
||||
@@ -1246,34 +1204,14 @@ class ExtensionManager:
|
||||
|
||||
# Check if already installed
|
||||
if self.registry.is_installed(manifest.id):
|
||||
if not force:
|
||||
raise ExtensionError(
|
||||
f"Extension '{manifest.id}' is already installed. "
|
||||
f"Use 'specify extension remove {manifest.id}' first, "
|
||||
f"or retry with --force to overwrite."
|
||||
)
|
||||
raise ExtensionError(
|
||||
f"Extension '{manifest.id}' is already installed. "
|
||||
f"Use 'specify extension remove {manifest.id}' first."
|
||||
)
|
||||
|
||||
# Reject manifests that would shadow core commands or installed extensions.
|
||||
self._validate_install_conflicts(manifest)
|
||||
|
||||
# Remove existing installation AFTER all validations pass so that a
|
||||
# validation failure doesn't leave the user with a half-uninstalled
|
||||
# extension (configs stranded in .backup/).
|
||||
did_remove = False
|
||||
if force and self.registry.is_installed(manifest.id):
|
||||
# Clear any stale backup from a previous remove so that only the
|
||||
# backup produced by the current remove() call is restored later.
|
||||
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
|
||||
# Check is_symlink first: is_dir() follows symlinks so a
|
||||
# symlink-to-directory would pass, but rmtree() raises on them.
|
||||
if backup_config_dir.is_symlink():
|
||||
backup_config_dir.unlink()
|
||||
elif backup_config_dir.is_dir():
|
||||
shutil.rmtree(backup_config_dir)
|
||||
elif backup_config_dir.exists():
|
||||
backup_config_dir.unlink()
|
||||
did_remove = self.remove(manifest.id)
|
||||
|
||||
# Install extension
|
||||
dest_dir = self.extensions_dir / manifest.id
|
||||
if dest_dir.exists():
|
||||
@@ -1288,11 +1226,7 @@ class ExtensionManager:
|
||||
registrar = CommandRegistrar()
|
||||
# Register for all detected agents
|
||||
registered_commands = registrar.register_commands_for_all_agents(
|
||||
manifest,
|
||||
dest_dir,
|
||||
self.project_root,
|
||||
link_outputs=link_commands,
|
||||
create_missing_active_skills_dir=True,
|
||||
manifest, dest_dir, self.project_root, link_outputs=link_commands
|
||||
)
|
||||
|
||||
# Auto-register extension commands as agent skills when --ai-skills
|
||||
@@ -1305,26 +1239,6 @@ class ExtensionManager:
|
||||
hook_executor = HookExecutor(self.project_root)
|
||||
hook_executor.register_hooks(manifest)
|
||||
|
||||
# Restore config files from backup when --force triggered a removal.
|
||||
# Only restore *.yml config files to match what remove() backs up,
|
||||
# so unexpected artifacts in .backup/ are not resurrected.
|
||||
if did_remove:
|
||||
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
|
||||
# is_symlink first: is_dir() follows symlinks, but rmtree()
|
||||
# raises on them — and we shouldn't follow symlinks to restore.
|
||||
if backup_config_dir.is_symlink():
|
||||
backup_config_dir.unlink()
|
||||
elif backup_config_dir.is_dir():
|
||||
for cfg_file in backup_config_dir.iterdir():
|
||||
if cfg_file.is_file() and not cfg_file.is_symlink() and (
|
||||
cfg_file.name.endswith("-config.yml") or
|
||||
cfg_file.name.endswith("-config.local.yml")
|
||||
):
|
||||
shutil.copy2(cfg_file, dest_dir / cfg_file.name)
|
||||
shutil.rmtree(backup_config_dir)
|
||||
elif backup_config_dir.exists():
|
||||
backup_config_dir.unlink()
|
||||
|
||||
# Update registry
|
||||
self.registry.add(manifest.id, {
|
||||
"version": manifest.version,
|
||||
@@ -1343,7 +1257,6 @@ class ExtensionManager:
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
) -> ExtensionManifest:
|
||||
"""Install extension from ZIP file.
|
||||
|
||||
@@ -1351,8 +1264,6 @@ class ExtensionManager:
|
||||
zip_path: Path to extension ZIP file
|
||||
speckit_version: Current spec-kit version
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
force: If True and extension is already installed, remove it first
|
||||
before proceeding with installation
|
||||
|
||||
Returns:
|
||||
Installed extension manifest
|
||||
@@ -1399,9 +1310,7 @@ class ExtensionManager:
|
||||
raise ValidationError("No extension.yml found in ZIP file")
|
||||
|
||||
# Install from extracted directory
|
||||
return self.install_from_directory(
|
||||
extension_dir, speckit_version, priority=priority, force=force
|
||||
)
|
||||
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
|
||||
|
||||
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
|
||||
"""Remove an installed extension.
|
||||
@@ -1583,10 +1492,9 @@ class ExtensionManager:
|
||||
init_options = {}
|
||||
|
||||
active_agent = init_options.get("ai")
|
||||
ai_skills_enabled = is_ai_skills_enabled(init_options)
|
||||
skills_mode_active = (
|
||||
active_agent == agent_name
|
||||
and ai_skills_enabled
|
||||
and bool(init_options.get("ai_skills"))
|
||||
and bool(agent_config)
|
||||
and agent_config.get("extension") != "/SKILL.md"
|
||||
)
|
||||
@@ -1780,7 +1688,6 @@ class CommandRegistrar:
|
||||
extension_dir: Path,
|
||||
project_root: Path,
|
||||
link_outputs: bool = False,
|
||||
create_missing_active_skills_dir: bool = False,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register extension commands for all detected agents."""
|
||||
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
|
||||
@@ -1788,7 +1695,6 @@ class CommandRegistrar:
|
||||
manifest.commands, manifest.id, extension_dir, project_root,
|
||||
context_note=context_note,
|
||||
link_outputs=link_outputs,
|
||||
create_missing_active_skills_dir=create_missing_active_skills_dir,
|
||||
)
|
||||
|
||||
def unregister_commands(
|
||||
@@ -2576,11 +2482,10 @@ class HookExecutor:
|
||||
|
||||
init_options = self._load_init_options()
|
||||
selected_ai = init_options.get("ai")
|
||||
ai_skills_enabled = is_ai_skills_enabled(init_options)
|
||||
codex_skill_mode = selected_ai == "codex" and ai_skills_enabled
|
||||
claude_skill_mode = selected_ai == "claude" and ai_skills_enabled
|
||||
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
|
||||
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
cursor_skill_mode = selected_ai == "cursor-agent" and ai_skills_enabled
|
||||
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
|
||||
cline_mode = selected_ai == "cline"
|
||||
|
||||
skill_name = self._skill_name_from_command(command_id)
|
||||
@@ -2837,7 +2742,7 @@ class HookExecutor:
|
||||
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
# We don't save yet, as there are no hooks to unregister,
|
||||
# We don't save yet, as there are no hooks to unregister,
|
||||
# but unregister_extension above might have already saved a normalized config.
|
||||
return
|
||||
|
||||
|
||||
@@ -34,21 +34,6 @@ _HOOK_COMMAND_NOTE = (
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
_CORE_COMMAND_TEMPLATE_ORDER = (
|
||||
"analyze",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
)
|
||||
_CORE_COMMAND_TEMPLATE_RANK = {
|
||||
command: index for index, command in enumerate(_CORE_COMMAND_TEMPLATE_ORDER)
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationOption
|
||||
@@ -285,16 +270,6 @@ class IntegrationBase(ABC):
|
||||
)
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
# Windows: ``subprocess.run`` calls ``CreateProcess`` which does not
|
||||
# consult ``PATHEXT``, so a bare command name like ``cursor-agent``
|
||||
# that resolves to ``cursor-agent.cmd`` fails with ``WinError 2``.
|
||||
# Resolve via ``shutil.which`` (which does honor ``PATHEXT``) so
|
||||
# ``.cmd``/``.bat`` shims work transparently. On POSIX this is a
|
||||
# no-op for absolute paths and a harmless lookup otherwise.
|
||||
resolved = shutil.which(exec_args[0])
|
||||
if resolved:
|
||||
exec_args = [resolved, *exec_args[1:]]
|
||||
|
||||
cwd = str(project_root) if project_root else None
|
||||
|
||||
if stream:
|
||||
@@ -370,19 +345,11 @@ class IntegrationBase(ABC):
|
||||
return None
|
||||
|
||||
def list_command_templates(self) -> list[Path]:
|
||||
"""Return ordered list of command template files from the shared directory."""
|
||||
"""Return sorted list of command template files from the shared directory."""
|
||||
cmd_dir = self.shared_commands_dir()
|
||||
if not cmd_dir or not cmd_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md"),
|
||||
key=lambda f: (
|
||||
_CORE_COMMAND_TEMPLATE_RANK.get(
|
||||
f.stem, len(_CORE_COMMAND_TEMPLATE_ORDER)
|
||||
),
|
||||
f.name,
|
||||
),
|
||||
)
|
||||
return sorted(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md")
|
||||
|
||||
def command_filename(self, template_name: str) -> str:
|
||||
"""Return the destination filename for a command template.
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
|
||||
Cursor Agent uses the ``.cursor/skills/speckit-<name>/SKILL.md`` layout.
|
||||
Commands are deprecated; ``--skills`` defaults to ``True``.
|
||||
|
||||
The IDE/skills flow is the primary path and works without the
|
||||
``cursor-agent`` CLI being installed (``requires_cli=False``). Workflow
|
||||
dispatch via ``cursor-agent -p --trust --approve-mcps --force <prompt>``
|
||||
is offered as an opt-in capability — the presence of ``build_exec_args()``
|
||||
is what indicates dispatch support, mirroring ``CopilotIntegration``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,12 +15,7 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
"name": "Cursor",
|
||||
"folder": ".cursor/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://docs.cursor.com/en/cli/overview",
|
||||
# IDE-first integration: ``specify init --ai cursor-agent`` must
|
||||
# work without the ``cursor-agent`` CLI installed (the IDE flow
|
||||
# uses skills directly). Workflow dispatch additionally requires
|
||||
# the CLI on PATH, but that's enforced at dispatch time via
|
||||
# ``shutil.which`` rather than as a hard ``specify init`` precheck.
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
@@ -39,50 +28,6 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
context_file = ".cursor/rules/specify-rules.mdc"
|
||||
multi_install_safe = True
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
"""Build CLI arguments for non-interactive ``cursor-agent`` execution.
|
||||
|
||||
Always returns argv (no ``requires_cli`` guard) so workflow
|
||||
dispatch is supported even though the integration's ``config``
|
||||
sets ``requires_cli=False`` to keep the IDE-only flow unblocked.
|
||||
This mirrors ``CopilotIntegration``: dispatch support is signalled
|
||||
by overriding ``build_exec_args()``, not by the ``requires_cli``
|
||||
flag (which is reserved for the ``specify init`` precheck).
|
||||
|
||||
Mandatory headless flags:
|
||||
|
||||
* ``-p`` — print/headless mode (access to all tools)
|
||||
* ``--trust`` — bypass Workspace Trust prompt (CLI exits non-zero
|
||||
otherwise)
|
||||
* ``--approve-mcps`` — auto-approve MCP server loading (otherwise
|
||||
MCP servers stay ``not loaded (needs approval)`` and tool calls
|
||||
to them are silently dropped)
|
||||
* ``--force`` — auto-approve tool invocations (shell/write/MCP),
|
||||
matching the implicit "trusted environment" semantics that other
|
||||
integrations (``claude -p``, ``codex --exec``) get by default
|
||||
|
||||
Together these are the minimum set required to make
|
||||
``specify workflow run speckit --input integration=cursor-agent``
|
||||
behave the same way as it does for ``claude`` / ``codex``.
|
||||
Verified locally: with ``--approve-mcps --force`` the agent can
|
||||
call any configured MCP server (e.g. ``dingtalk-doc``) and write
|
||||
files during ``/speckit-*`` skill execution; without them the run
|
||||
either drops tool calls or exits non-zero on the first approval
|
||||
prompt.
|
||||
"""
|
||||
args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt]
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
args.extend(["--output-format", "json"])
|
||||
return args
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
|
||||
@@ -29,7 +29,6 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
||||
from .integrations.base import IntegrationBase
|
||||
from ._init_options import is_ai_skills_enabled
|
||||
|
||||
|
||||
def _substitute_core_template(
|
||||
@@ -1263,7 +1262,7 @@ class PresetManager:
|
||||
selected_ai = init_opts.get("ai")
|
||||
if not isinstance(selected_ai, str):
|
||||
return []
|
||||
ai_skills_enabled = is_ai_skills_enabled(init_opts)
|
||||
ai_skills_enabled = bool(init_opts.get("ai_skills"))
|
||||
registrar = CommandRegistrar()
|
||||
integration = get_integration(selected_ai)
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -195,37 +194,6 @@ def _write_shared_bytes(
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
_BASH_FORMAT_COMMAND_RE = re.compile(
|
||||
r"\$\(\s*format_speckit_command\s+(['\"]?)([A-Za-z0-9_.-]+)\1(?:\s+[^)]*)?\)"
|
||||
)
|
||||
_POWERSHELL_FORMAT_COMMAND_RE = re.compile(
|
||||
r"Format-SpecKitCommand\s+-CommandName\s+(['\"])([A-Za-z0-9_.-]+)\1(?:\s+-RepoRoot\s+[^\r\n]+)?"
|
||||
)
|
||||
|
||||
|
||||
def _format_speckit_command(command_name: str, separator: str) -> str:
|
||||
name = command_name.strip().lstrip("/")
|
||||
if name.startswith("speckit."):
|
||||
name = name[len("speckit.") :]
|
||||
elif name.startswith("speckit-"):
|
||||
name = name[len("speckit-") :]
|
||||
name = name.replace(".", separator)
|
||||
return f"/speckit{separator}{name}"
|
||||
|
||||
|
||||
def _resolve_dynamic_command_refs(content: str, separator: str) -> str:
|
||||
"""Render script runtime command helpers for managed shared infra copies."""
|
||||
|
||||
content = _BASH_FORMAT_COMMAND_RE.sub(
|
||||
lambda match: _format_speckit_command(match.group(2), separator),
|
||||
content,
|
||||
)
|
||||
return _POWERSHELL_FORMAT_COMMAND_RE.sub(
|
||||
lambda match: f"'{_format_speckit_command(match.group(2), separator)}'",
|
||||
content,
|
||||
)
|
||||
|
||||
|
||||
def refresh_shared_templates(
|
||||
project_path: Path,
|
||||
*,
|
||||
@@ -420,7 +388,6 @@ def install_shared_infra(
|
||||
continue
|
||||
content = src_path.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
content = _resolve_dynamic_command_refs(content, invoke_separator)
|
||||
planned_copies.append(
|
||||
(
|
||||
dst_path,
|
||||
|
||||
@@ -281,49 +281,16 @@ def _validate_steps(
|
||||
class RunState:
|
||||
"""Manages workflow run state for persistence and resume."""
|
||||
|
||||
# ``run_id`` is interpolated into a filesystem path (``runs/<run_id>``)
|
||||
# by both ``save()`` and ``load()``. Constrain it to a charset that
|
||||
# cannot contain path separators (``/`` ``\``), parent-directory
|
||||
# segments (``..``), or NULs — anything that could escape the
|
||||
# ``.specify/workflows/runs/`` directory or be mis-interpreted by the
|
||||
# filesystem. The first-character anchor blocks IDs that start with
|
||||
# ``-`` (which would be mistaken for a CLI flag in error messages
|
||||
# and shell completions).
|
||||
_RUN_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
||||
|
||||
@classmethod
|
||||
def _validate_run_id(cls, run_id: str) -> None:
|
||||
"""Raise ``ValueError`` if ``run_id`` is not a safe path component.
|
||||
|
||||
This is the single source of truth for what counts as a valid
|
||||
``run_id``. ``__init__`` calls it to reject malformed IDs at
|
||||
construction time; ``load`` calls it *before* interpolating the
|
||||
ID into a path so a malicious value cannot probe or read files
|
||||
outside ``.specify/workflows/runs/<run_id>/``.
|
||||
"""
|
||||
if not isinstance(run_id, str) or not cls._RUN_ID_PATTERN.match(run_id):
|
||||
raise ValueError(
|
||||
f"Invalid run_id {run_id!r}: must be alphanumeric with "
|
||||
"hyphens/underscores only (and must start with an "
|
||||
"alphanumeric character)."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
run_id: str | None = None,
|
||||
workflow_id: str = "",
|
||||
project_root: Path | None = None,
|
||||
) -> None:
|
||||
# ``run_id is None`` (omitted) → auto-generate. An explicit empty
|
||||
# string is *not* the same as "omitted" and must be validated like
|
||||
# any other caller-provided value — otherwise ``__init__("")``
|
||||
# would silently substitute a UUID while ``load("")`` rejects, and
|
||||
# the two entry points would diverge on the empty-string vector.
|
||||
if run_id is None:
|
||||
self.run_id = str(uuid.uuid4())[:8]
|
||||
else:
|
||||
self.run_id = run_id
|
||||
self._validate_run_id(self.run_id)
|
||||
self.run_id = run_id or str(uuid.uuid4())[:8]
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$', self.run_id):
|
||||
msg = f"Invalid run_id {self.run_id!r}: must be alphanumeric with hyphens/underscores only."
|
||||
raise ValueError(msg)
|
||||
self.workflow_id = workflow_id
|
||||
self.project_root = project_root or Path(".")
|
||||
self.status = RunStatus.CREATED
|
||||
@@ -364,20 +331,7 @@ class RunState:
|
||||
|
||||
@classmethod
|
||||
def load(cls, run_id: str, project_root: Path) -> RunState:
|
||||
"""Load a run state from disk.
|
||||
|
||||
Validates ``run_id`` against ``_RUN_ID_PATTERN`` *before* building
|
||||
the lookup path. Without this guard, a caller passing a value like
|
||||
``../escape`` (e.g. via ``specify workflow resume`` CLI argument)
|
||||
would interpolate path-traversal segments into
|
||||
``runs_dir`` below, letting ``state_path.exists()`` probe arbitrary
|
||||
paths and ``json.load`` read attacker-planted JSON from outside
|
||||
the project's ``runs/`` directory. ``__init__`` already runs this
|
||||
check on the stored ``state_data["run_id"]``, but that fires
|
||||
*after* the file lookup — too late to prevent the disclosure.
|
||||
Mirrors the precedent in ``agents._ensure_within_directory``.
|
||||
"""
|
||||
cls._validate_run_id(run_id)
|
||||
"""Load a run state from disk."""
|
||||
runs_dir = project_root / ".specify" / "workflows" / "runs" / run_id
|
||||
state_path = runs_dir / "state.json"
|
||||
if not state_path.exists():
|
||||
@@ -449,10 +403,10 @@ class WorkflowEngine:
|
||||
ValueError:
|
||||
If the workflow YAML is invalid.
|
||||
"""
|
||||
path = Path(source).expanduser()
|
||||
path = Path(source)
|
||||
|
||||
# Try as a direct file path first
|
||||
if path.suffix.lower() in (".yml", ".yaml") and path.is_file():
|
||||
if path.suffix in (".yml", ".yaml") and path.exists():
|
||||
return WorkflowDefinition.from_yaml(path)
|
||||
|
||||
# Try as an installed workflow ID
|
||||
@@ -553,19 +507,8 @@ class WorkflowEngine:
|
||||
state.save()
|
||||
return state
|
||||
|
||||
def resume(
|
||||
self,
|
||||
run_id: str,
|
||||
inputs: dict[str, Any] | None = None,
|
||||
) -> RunState:
|
||||
"""Resume a paused or failed workflow run.
|
||||
|
||||
When ``inputs`` is provided, the values are merged over the run's
|
||||
persisted inputs and re-resolved through the same typed validation
|
||||
path used by :meth:`execute`, so the resumed step sees updated
|
||||
workflow inputs. Keys not supplied keep their persisted values; an
|
||||
empty/``None`` ``inputs`` leaves the run's inputs unchanged.
|
||||
"""
|
||||
def resume(self, run_id: str) -> RunState:
|
||||
"""Resume a paused or failed workflow run."""
|
||||
state = RunState.load(run_id, self.project_root)
|
||||
if state.status not in (RunStatus.PAUSED, RunStatus.FAILED):
|
||||
msg = f"Cannot resume run {run_id!r} with status {state.status.value!r}."
|
||||
@@ -581,12 +524,6 @@ class WorkflowEngine:
|
||||
else:
|
||||
definition = self.load_workflow(state.workflow_id)
|
||||
|
||||
# Merge any newly-supplied inputs over the persisted ones and
|
||||
# re-validate through the same typing path as the initial run.
|
||||
if inputs:
|
||||
merged = {**state.inputs, **inputs}
|
||||
state.inputs = self._resolve_inputs(definition, merged)
|
||||
|
||||
# Restore context
|
||||
context = StepContext(
|
||||
inputs=state.inputs,
|
||||
|
||||
@@ -147,14 +147,7 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate
|
||||
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
||||
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||
|
||||
3. **Create quickstart validation guide** → `quickstart.md`:
|
||||
- Document runnable validation scenarios that prove the feature works end-to-end
|
||||
- Include prerequisites, setup commands, test/run commands, and expected outcomes
|
||||
- Use links or references to contracts and data model details instead of duplicating them
|
||||
- Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites
|
||||
- Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase
|
||||
|
||||
4. **Agent context update**:
|
||||
3. **Agent context update**:
|
||||
- Update the plan reference between the `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path)
|
||||
|
||||
**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file
|
||||
|
||||
@@ -81,72 +81,3 @@ def _isolate_auth_config(monkeypatch):
|
||||
# Also clear the per-process cache so tests that unset _config_override
|
||||
# won't see a previously cached real-file result.
|
||||
monkeypatch.setattr(_auth_http, "_config_cache", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_environ(monkeypatch):
|
||||
"""Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment."""
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
|
||||
|
||||
def _fake_self_upgrade_argv0(monkeypatch, tmp_path, env_name, path_parts):
|
||||
"""Create a fake executable under tmp_path and point sys.argv[0] at it."""
|
||||
monkeypatch.setenv(env_name, str(tmp_path))
|
||||
fake_dir = tmp_path.joinpath(*path_parts)
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify")
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
fake_specify.chmod(0o755)
|
||||
monkeypatch.setattr("sys.argv", [str(fake_specify)])
|
||||
return fake_specify
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uv_tool_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin")
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
"HOME",
|
||||
(".local", "share", "uv", "tools", "specify-cli", "bin"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipx_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated pipx install path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin")
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uvx_ephemeral_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
"LOCALAPPDATA",
|
||||
("uv", "cache", "archive-v0", "abc123", "bin"),
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unsupported_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a path that does not match any installer prefix."""
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", ("random", "location", "bin")
|
||||
)
|
||||
|
||||
@@ -371,7 +371,7 @@ class TestCreateFeaturePowerShell:
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
# pwsh may prefix warnings to stdout; find the JSON line
|
||||
json_line = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")]
|
||||
json_line = [l for l in result.stdout.splitlines() if l.strip().startswith("{")]
|
||||
assert json_line, f"No JSON in output: {result.stdout}"
|
||||
data = json.loads(json_line[-1])
|
||||
assert "BRANCH_NAME" in data
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
"""HTTP test helpers shared by version-related CLI tests."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
"""Build a urlopen context-manager mock whose read returns JSON."""
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
@@ -121,11 +121,6 @@ class TestBasePrimitives:
|
||||
assert len(templates) > 0
|
||||
assert all(t.suffix == ".md" for t in templates)
|
||||
|
||||
def test_list_command_templates_keeps_checklist_after_plan(self):
|
||||
i = StubIntegration()
|
||||
stems = [template.stem for template in i.list_command_templates()]
|
||||
assert stems.index("plan") < stems.index("checklist")
|
||||
|
||||
def test_command_filename_default(self):
|
||||
i = StubIntegration()
|
||||
assert i.command_filename("plan") == "speckit.plan.md"
|
||||
|
||||
@@ -131,5 +131,5 @@ class TestAgyHookCommandNote:
|
||||
)
|
||||
result = AgyIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [ln for ln in lines if "replace dots" in ln][0]
|
||||
note_line = [l for l in lines if "replace dots" in l][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
@@ -254,8 +254,8 @@ class MarkdownIntegrationTests:
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
@@ -269,10 +269,10 @@ class MarkdownIntegrationTests:
|
||||
files.append(f"{cmd_dir}/speckit.{stem}.md")
|
||||
|
||||
# Framework files
|
||||
files.append(".specify/integration.json")
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integration.json")
|
||||
files.append(f".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
files.append(f".specify/integrations/speckit.manifest.json")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
|
||||
|
||||
@@ -100,8 +100,8 @@ class SkillsIntegrationTests:
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
expected_commands = {
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
}
|
||||
|
||||
# Derive command names from the skill directory names
|
||||
@@ -393,8 +393,8 @@ class SkillsIntegrationTests:
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
|
||||
@@ -486,11 +486,11 @@ class TomlIntegrationTests:
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
|
||||
@@ -152,7 +152,7 @@ class YamlIntegrationTests:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
# Strip trailing source comment before parsing
|
||||
lines = content.split("\n")
|
||||
yaml_lines = [ln for ln in lines if not ln.startswith("# Source:")]
|
||||
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
|
||||
try:
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
except Exception as exc:
|
||||
@@ -183,7 +183,7 @@ class YamlIntegrationTests:
|
||||
content = cmd_files[0].read_text(encoding="utf-8")
|
||||
# Strip source comment for parsing
|
||||
lines = content.split("\n")
|
||||
yaml_lines = [ln for ln in lines if not ln.startswith("# Source:")]
|
||||
yaml_lines = [l for l in lines if not l.startswith("# Source:")]
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
|
||||
assert "description:" not in parsed["prompt"]
|
||||
@@ -365,11 +365,11 @@ class YamlIntegrationTests:
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
|
||||
@@ -127,8 +127,8 @@ class TestCopilotIntegration:
|
||||
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
|
||||
assert len(agent_files) == 9
|
||||
expected_commands = {
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
}
|
||||
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files}
|
||||
assert actual_commands == expected_commands
|
||||
@@ -321,8 +321,8 @@ class TestCopilotSkillsMode:
|
||||
"""Tests for Copilot integration in --skills mode."""
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _make_copilot(self):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for CursorAgentIntegration."""
|
||||
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
@@ -107,157 +106,3 @@ class TestCursorAgentAutoPromote:
|
||||
assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}"
|
||||
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestCursorAgentCliDispatch:
|
||||
"""Verify the CLI dispatch path for cursor-agent (issue #2629).
|
||||
|
||||
The ``cursor-agent`` CLI supports headless execution via ``-p`` (with
|
||||
full tool access including write/shell) and requires ``--trust`` to
|
||||
bypass the Workspace Trust prompt. These tests pin the exact argv
|
||||
shape that the workflow runner will use.
|
||||
"""
|
||||
|
||||
def test_requires_cli_is_false_for_ide_first_flow(self):
|
||||
"""``requires_cli`` must stay False so the IDE-only flow keeps working.
|
||||
|
||||
``specify init --ai cursor-agent`` (without ``--ignore-agent-tools``)
|
||||
treats ``requires_cli=True`` as a hard precheck and fails when the
|
||||
``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE
|
||||
/ skills flow can run without it. Workflow dispatch support is
|
||||
signalled by overriding ``build_exec_args()`` instead, mirroring
|
||||
``CopilotIntegration``.
|
||||
"""
|
||||
i = get_integration("cursor-agent")
|
||||
assert i.config.get("requires_cli") is False
|
||||
|
||||
def test_install_url_is_set(self):
|
||||
i = get_integration("cursor-agent")
|
||||
url = i.config.get("install_url")
|
||||
assert url is not None
|
||||
# CodeQL: use a hostname comparison instead of a substring check
|
||||
# to avoid the "Incomplete URL substring sanitization" warning
|
||||
# (substring "cursor.com" can also appear in attacker-controlled
|
||||
# positions of an arbitrary URL).
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
assert host == "cursor.com" or host.endswith(".cursor.com")
|
||||
|
||||
def test_build_exec_args_default_includes_headless_flags_and_json(self):
|
||||
"""Default argv emits the full headless flag set: -p --trust
|
||||
--approve-mcps --force, then prompt, then --output-format json.
|
||||
"""
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args("/speckit-specify some-feature")
|
||||
assert args == [
|
||||
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
|
||||
"/speckit-specify some-feature",
|
||||
"--output-format", "json",
|
||||
]
|
||||
|
||||
def test_build_exec_args_text_output_omits_format(self):
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args("/speckit-plan", output_json=False)
|
||||
assert args == [
|
||||
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
|
||||
"/speckit-plan",
|
||||
]
|
||||
|
||||
def test_build_exec_args_with_model(self):
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args(
|
||||
"/speckit-specify", model="sonnet-4-thinking", output_json=False
|
||||
)
|
||||
assert args == [
|
||||
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
|
||||
"/speckit-specify",
|
||||
"--model", "sonnet-4-thinking",
|
||||
]
|
||||
|
||||
def test_build_exec_args_contains_mandatory_headless_flags(self):
|
||||
"""The four headless flags must always appear together.
|
||||
|
||||
``--approve-mcps`` is required so MCP servers (e.g. dingtalk-doc)
|
||||
actually load in headless mode; ``--force`` is required so the
|
||||
agent doesn't block on tool-call approval prompts during the
|
||||
speckit workflow. Together with ``-p`` and ``--trust`` they
|
||||
bring cursor-agent's headless behaviour in line with
|
||||
``claude -p`` / ``codex --exec`` from spec-kit's perspective.
|
||||
"""
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args("/speckit-implement", output_json=False)
|
||||
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
|
||||
assert flag in args, f"missing mandatory headless flag: {flag}"
|
||||
|
||||
def test_build_exec_args_supports_dispatch_without_requires_cli(self):
|
||||
"""``build_exec_args`` must return argv even though ``requires_cli``
|
||||
is ``False``.
|
||||
|
||||
``CursorAgentIntegration`` opts out of the ``requires_cli`` hard
|
||||
precheck (so ``specify init`` doesn't fail when the CLI isn't on
|
||||
PATH) but still supports workflow dispatch. The presence of a
|
||||
non-``None`` argv from ``build_exec_args()`` is what the engine
|
||||
keys off — pin that invariant.
|
||||
"""
|
||||
i = get_integration("cursor-agent")
|
||||
assert i.config.get("requires_cli") is False
|
||||
argv = i.build_exec_args("/speckit-plan", output_json=False)
|
||||
assert argv is not None
|
||||
assert argv[0] == "cursor-agent"
|
||||
|
||||
def test_build_command_invocation_uses_hyphenated_skill_name(self):
|
||||
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
|
||||
i = get_integration("cursor-agent")
|
||||
assert i.build_command_invocation("speckit.plan", "feature-x") == "/speckit-plan feature-x"
|
||||
assert i.build_command_invocation("plan") == "/speckit-plan"
|
||||
|
||||
def test_dispatch_command_resolves_cmd_shim_for_subprocess(self):
|
||||
"""``.cmd`` shims must be resolved to their full path before ``subprocess.run``.
|
||||
|
||||
``cursor-agent`` (and other npm-installed CLIs on Windows) ship as
|
||||
``cursor-agent.cmd`` wrappers. ``shutil.which`` honors ``PATHEXT``
|
||||
and finds them, but Python's ``subprocess.run`` calls
|
||||
``CreateProcess`` which does **not** consult ``PATHEXT`` and fails
|
||||
with ``WinError 2`` on a bare ``["cursor-agent", ...]`` argv. The
|
||||
fix in ``base.py::dispatch_command`` resolves ``exec_args[0]`` via
|
||||
``shutil.which`` so the full ``.cmd`` path is what reaches
|
||||
``CreateProcess``.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
i = get_integration("cursor-agent")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "ok"
|
||||
mock_result.stderr = ""
|
||||
|
||||
fake_path = r"C:\Users\foo\AppData\Local\cursor-agent\cursor-agent.CMD"
|
||||
with patch(
|
||||
"specify_cli.integrations.base.shutil.which", return_value=fake_path
|
||||
), patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = i.dispatch_command(
|
||||
"speckit.plan", args="feature-x", stream=False, timeout=5
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == fake_path, f"expected resolved .CMD path, got: {argv[0]!r}"
|
||||
assert argv[1:6] == ["-p", "--trust", "--approve-mcps", "--force", "/speckit-plan feature-x"]
|
||||
|
||||
def test_dispatch_command_passthrough_when_shutil_which_finds_nothing(self):
|
||||
"""If ``shutil.which`` returns ``None``, leave argv unchanged so the
|
||||
existing ``FileNotFoundError`` path remains observable to callers."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
i = get_integration("cursor-agent")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch(
|
||||
"specify_cli.integrations.base.shutil.which", return_value=None
|
||||
), patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
i.dispatch_command("speckit.plan", stream=False, timeout=5)
|
||||
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == "cursor-agent"
|
||||
|
||||
|
||||
@@ -185,20 +185,6 @@ class TestGenericIntegration:
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_plan_defines_quickstart_as_validation_guide(self, tmp_path):
|
||||
"""The generated plan command should keep quickstart.md out of implementation scope."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
|
||||
assert "Create quickstart validation guide" in content
|
||||
assert "runnable validation scenarios" in content
|
||||
assert "Do not include full implementation code" in content
|
||||
assert "implementation details belong in `tasks.md` and the implementation phase" in content
|
||||
|
||||
def test_implement_loads_constitution_context(self, tmp_path):
|
||||
"""The generated implement command should load constitution governance context."""
|
||||
i = get_integration("generic")
|
||||
@@ -213,10 +199,10 @@ class TestGenericIntegration:
|
||||
"command_stem",
|
||||
[
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Shared fixtures and helpers for `specify self upgrade` tests.
|
||||
|
||||
These helpers patch subprocess, PATH lookup, and release-tag resolution so
|
||||
the focused test modules stay isolated from the real environment.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli._version import (
|
||||
_InstallMethod,
|
||||
_UpgradePlan,
|
||||
_assemble_installer_argv,
|
||||
_detect_install_method,
|
||||
_verify_upgrade,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
from tests.http_helpers import mock_urlopen_response
|
||||
|
||||
__all__ = (
|
||||
"SENTINEL_GH_TOKEN",
|
||||
"SENTINEL_GITHUB_TOKEN",
|
||||
"_InstallMethod",
|
||||
"_UpgradePlan",
|
||||
"_assemble_installer_argv",
|
||||
"_completed_process",
|
||||
"_detect_install_method",
|
||||
"_verify_upgrade",
|
||||
"mock_urlopen_response",
|
||||
"requires_posix",
|
||||
"runner",
|
||||
"strip_ansi",
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Some installer error-path tests create a relative `./uv` fixture, `chdir`
|
||||
# into the tmp dir, and assert POSIX executable-bit semantics (chmod / X_OK).
|
||||
# None of that maps cleanly onto Windows: `os.access(path, X_OK)` ignores the
|
||||
# mode bits, and pytest cannot rmtree a tmp dir that is still the cwd, so the
|
||||
# fixtures raise PermissionError during teardown. Skip these on Windows — the
|
||||
# realistic absolute-path and bare-PATH-command branches stay covered there.
|
||||
requires_posix = pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason="relative-path / executable-bit semantics are POSIX-only",
|
||||
)
|
||||
|
||||
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
||||
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
|
||||
|
||||
|
||||
def _completed_process(
|
||||
returncode: int, stdout: str = "", stderr: str = ""
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Build a subprocess.CompletedProcess for installer / verification calls."""
|
||||
return subprocess.CompletedProcess(
|
||||
args=["mocked"],
|
||||
returncode=returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
@@ -573,9 +573,7 @@ class TestAuthenticatedHttp:
|
||||
mock_opener = MagicMock()
|
||||
def fake_open(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener.open.side_effect = fake_open
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
@@ -590,9 +588,7 @@ class TestAuthenticatedHttp:
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://example.com/file.json")
|
||||
@@ -605,9 +601,7 @@ class TestAuthenticatedHttp:
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://github.com/org/repo")
|
||||
@@ -621,16 +615,12 @@ class TestAuthenticatedHttp:
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
call_count = 0
|
||||
def fake_side_effect(req, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
nonlocal call_count; call_count += 1
|
||||
if call_count == 1:
|
||||
raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
resp = MagicMock(); resp.__enter__ = lambda s: s; resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = fake_side_effect
|
||||
mock_opener = MagicMock(); mock_opener.open.side_effect = fake_side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
|
||||
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
|
||||
open_url("https://github.com/org/repo")
|
||||
@@ -702,6 +692,7 @@ class TestLoadConfigCaching:
|
||||
"""_load_config() should call load_auth_config only once per process."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication import http as _mod
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
# Allow the real load path (no override)
|
||||
monkeypatch.setattr(_mod, "_config_override", None)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
@@ -834,11 +825,8 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
def side_effect(req, timeout=None):
|
||||
captured["request"] = req
|
||||
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
resp = MagicMock(); resp.read.return_value = body
|
||||
cm = MagicMock(); cm.__enter__.return_value = resp; cm.__exit__.return_value = False
|
||||
return cm
|
||||
return captured, side_effect
|
||||
|
||||
@@ -848,8 +836,7 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured, side_effect = self._capture_request()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
mock_opener = MagicMock(); mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
|
||||
|
||||
@@ -29,7 +29,7 @@ def test_agent_config_importable():
|
||||
|
||||
|
||||
def test_agent_config_re_exported_from_init():
|
||||
from specify_cli import AGENT_CONFIG, SCRIPT_TYPE_CHOICES
|
||||
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
from specify_cli import (
|
||||
console,
|
||||
StepTracker,
|
||||
get_key,
|
||||
select_with_arrows,
|
||||
BannerGroup,
|
||||
show_banner,
|
||||
BANNER,
|
||||
TAGLINE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,19 +17,17 @@ import tempfile
|
||||
import shutil
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from specify_cli.extensions import (
|
||||
ExtensionManifest,
|
||||
ExtensionManager,
|
||||
ExtensionError,
|
||||
)
|
||||
|
||||
|
||||
# ===== Helpers =====
|
||||
|
||||
def _create_init_options(
|
||||
project_root: Path, ai: str = "claude", ai_skills: Any = True
|
||||
):
|
||||
def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True):
|
||||
"""Write a .specify/init-options.json file."""
|
||||
opts_dir = project_root / ".specify"
|
||||
opts_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -38,7 +36,7 @@ def _create_init_options(
|
||||
"ai": ai,
|
||||
"ai_skills": ai_skills,
|
||||
"script": "sh",
|
||||
}), encoding="utf-8")
|
||||
}))
|
||||
|
||||
|
||||
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
|
||||
@@ -223,20 +221,11 @@ class TestExtensionManagerGetSkillsDir:
|
||||
result = manager._get_skills_dir()
|
||||
assert result == skills_dir
|
||||
|
||||
def test_returns_none_when_ai_skills_is_non_boolean_truthy(self, project_dir):
|
||||
"""Corrupted truthy ai_skills values should not enable skills mode."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills="false")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
assert result is None
|
||||
assert not (project_dir / ".claude" / "skills").exists()
|
||||
|
||||
def test_returns_none_for_non_dict_init_options(self, project_dir):
|
||||
"""Corrupted-but-parseable init-options should not crash skill-dir lookup."""
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text("[]", encoding="utf-8")
|
||||
opts_file.write_text("[]")
|
||||
_create_skills_dir(project_dir, ai="claude")
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
@@ -252,7 +241,7 @@ class TestExtensionSkillRegistration:
|
||||
"""Skills should be created when ai_skills is enabled."""
|
||||
project_dir, skills_dir = skills_project
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
@@ -667,393 +656,6 @@ class TestExtensionSkillRegistration:
|
||||
assert "speckit-early-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-early-ext-world" in metadata["registered_skills"]
|
||||
|
||||
def test_commands_registered_when_claude_skills_dir_missing(self, project_dir, temp_dir):
|
||||
"""Extension install should not silently skip Claude when skills dir is missing."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
(project_dir / ".claude").mkdir()
|
||||
# Deliberately do NOT create .claude/skills
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_commands"] == {
|
||||
"claude": [
|
||||
"speckit.early-ext.hello",
|
||||
"speckit.early-ext.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
skill_file = skills_dir / "speckit-early-ext-hello" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "source: early-ext:commands/hello.md" in content
|
||||
|
||||
def test_hermes_global_skills_dir_used_when_marker_is_recovered(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Hermes recovery must not use the project marker as the output dir."""
|
||||
home = temp_dir / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
_create_init_options(project_dir, ai="hermes", ai_skills=True)
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {
|
||||
"hermes": [
|
||||
"speckit.early-ext.hello",
|
||||
"speckit.early-ext.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
global_skills_dir = home / ".hermes" / "skills"
|
||||
assert (
|
||||
global_skills_dir / "speckit-early-ext-hello" / "SKILL.md"
|
||||
).exists()
|
||||
assert (
|
||||
global_skills_dir / "speckit-early-ext-world" / "SKILL.md"
|
||||
).exists()
|
||||
|
||||
marker = project_dir / ".hermes" / "skills"
|
||||
assert marker.is_dir()
|
||||
assert list(marker.glob("speckit-*/SKILL.md")) == []
|
||||
|
||||
def test_hermes_get_skills_dir_creates_global_output_dir(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""ExtensionManager should create the agent-specific output dir it returns."""
|
||||
home = temp_dir / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
_create_init_options(project_dir, ai="hermes", ai_skills=True)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
skills_dir = manager._get_skills_dir()
|
||||
|
||||
assert skills_dir == home / ".hermes" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
assert (project_dir / ".hermes" / "skills").is_dir()
|
||||
|
||||
def test_unusable_hermes_global_skills_dir_skips_skill_registration(
|
||||
self, project_dir, temp_dir, monkeypatch, capsys
|
||||
):
|
||||
"""An unusable agent-specific output dir should warn and skip skills."""
|
||||
home = temp_dir / "home"
|
||||
hermes_dir = home / ".hermes"
|
||||
hermes_dir.mkdir(parents=True)
|
||||
(hermes_dir / "skills").write_text("not a directory", encoding="utf-8")
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
_create_init_options(project_dir, ai="hermes", ai_skills=True)
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="blocked-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_skills"] == []
|
||||
captured = capsys.readouterr()
|
||||
assert "Warning:" in captured.out
|
||||
assert "Continuing without skill registration." in captured.out
|
||||
|
||||
def test_detect_dir_marker_file_does_not_register_hermes_commands(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Regular files at detect_dir marker paths should not detect agents."""
|
||||
home = temp_dir / "home"
|
||||
global_skills_dir = home / ".hermes" / "skills"
|
||||
global_skills_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
_create_init_options(project_dir, ai="hermes", ai_skills=True)
|
||||
marker_parent = project_dir / ".hermes"
|
||||
marker_parent.mkdir()
|
||||
marker_file = marker_parent / "skills"
|
||||
marker_file.write_text("not a directory", encoding="utf-8")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
assert marker_file.is_file()
|
||||
assert marker_file.read_text(encoding="utf-8") == "not a directory"
|
||||
assert not (
|
||||
global_skills_dir / "speckit-early-ext-hello" / "SKILL.md"
|
||||
).exists()
|
||||
assert not (
|
||||
global_skills_dir / "speckit-early-ext-world" / "SKILL.md"
|
||||
).exists()
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_non_boolean_ai_skills_does_not_recover_missing_skills_dir(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Corrupted truthy ai_skills values should not recover skills dirs."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills="false")
|
||||
(project_dir / ".claude").mkdir()
|
||||
# Deliberately do NOT create .claude/skills.
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_skills"] == []
|
||||
assert not (project_dir / ".claude" / "skills").exists()
|
||||
|
||||
def test_non_boolean_ai_skills_does_not_skip_default_agent_reregistration(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Corrupted ai_skills values should not trigger skills-mode skips."""
|
||||
_create_init_options(project_dir, ai="copilot", ai_skills="false")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
manager.register_enabled_extensions_for_agent("copilot")
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {
|
||||
"copilot": [
|
||||
"speckit.early-ext.hello",
|
||||
"speckit.early-ext.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
assert (project_dir / ".github" / "agents").is_dir()
|
||||
|
||||
def test_existing_agent_command_path_file_is_not_detected(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Existing files at command-dir paths should not count as detected agents."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||
claude_dir = project_dir / ".claude"
|
||||
claude_dir.mkdir()
|
||||
skills_file = claude_dir / "skills"
|
||||
skills_file.write_text("not a directory", encoding="utf-8")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
assert skills_file.read_text(encoding="utf-8") == "not a directory"
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_missing_shared_skills_dir_registers_only_active_agent(self, project_dir, temp_dir):
|
||||
"""Recreating shared skills dirs should not activate unrelated agents."""
|
||||
_create_init_options(project_dir, ai="agy", ai_skills=True)
|
||||
(project_dir / ".agents").mkdir()
|
||||
# Deliberately do NOT create .agents/skills, shared by agy and codex.
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_commands"] == {
|
||||
"agy": [
|
||||
"speckit.early-ext.hello",
|
||||
"speckit.early-ext.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_missing_shared_skills_dir_uses_normalized_guard_for_later_agents(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Shared-dir suppression should tolerate lexical path differences."""
|
||||
_create_init_options(project_dir, ai="agy", ai_skills=True)
|
||||
(project_dir / ".agents").mkdir()
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
from specify_cli.agents import CommandRegistrar as AgentRegistrar
|
||||
|
||||
original_resolve_agent_dir = AgentRegistrar._resolve_agent_dir
|
||||
original_register_commands = AgentRegistrar.register_commands
|
||||
attempted_agents = []
|
||||
|
||||
def resolve_codex_with_parent_segment(self, agent_name, agent_config, root):
|
||||
if agent_name == "codex":
|
||||
return root / ".agents" / ".." / ".agents" / "skills"
|
||||
return original_resolve_agent_dir(agent_name, agent_config, root)
|
||||
|
||||
def record_registration(self, agent_name, *args, **kwargs):
|
||||
attempted_agents.append(agent_name)
|
||||
return original_register_commands(self, agent_name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentRegistrar, "_resolve_agent_dir", resolve_codex_with_parent_segment
|
||||
)
|
||||
monkeypatch.setattr(AgentRegistrar, "register_commands", record_registration)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
assert attempted_agents == ["agy"]
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {
|
||||
"agy": [
|
||||
"speckit.early-ext.hello",
|
||||
"speckit.early-ext.world",
|
||||
]
|
||||
}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_missing_shared_skills_dir_write_oserror_does_not_register_other_agents(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Failed active registration must not make shared skills dirs detected."""
|
||||
_create_init_options(project_dir, ai="agy", ai_skills=True)
|
||||
(project_dir / ".agents").mkdir()
|
||||
# Deliberately do NOT create .agents/skills, shared by agy and codex.
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
from specify_cli.agents import CommandRegistrar as AgentRegistrar
|
||||
|
||||
original_register_commands = AgentRegistrar.register_commands
|
||||
attempted_agents = []
|
||||
|
||||
def fail_recovered_agy_registration(self, agent_name, *args, **kwargs):
|
||||
attempted_agents.append(agent_name)
|
||||
if agent_name == "agy":
|
||||
raise PermissionError("denied")
|
||||
return original_register_commands(self, agent_name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentRegistrar, "register_commands", fail_recovered_agy_registration
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
assert attempted_agents == ["agy"]
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata is not None
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert "speckit-early-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-early-ext-world" in metadata["registered_skills"]
|
||||
|
||||
def test_missing_active_skills_dir_does_not_follow_symlinked_parent(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Recovered command registration must reuse active skills-dir safety checks."""
|
||||
if not hasattr(os, "symlink"):
|
||||
pytest.skip("symlinks are unavailable")
|
||||
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
outside = temp_dir / "outside-claude"
|
||||
outside.mkdir()
|
||||
try:
|
||||
os.symlink(outside, project_dir / ".claude", target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("Current platform/user cannot create directory symlinks")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_skills"] == []
|
||||
assert not (outside / "skills").exists()
|
||||
|
||||
def test_missing_active_skills_dir_invalid_parent_skips_without_aborting(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Invalid active skill parents should not abort extension installation."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
(project_dir / ".claude").write_text("not a directory", encoding="utf-8")
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert metadata["registered_skills"] == []
|
||||
|
||||
def test_missing_active_skills_dir_write_oserror_skips_without_aborting(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""Filesystem failures in recovered command registration should skip safely."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
(project_dir / ".claude").mkdir()
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
|
||||
|
||||
from specify_cli.agents import CommandRegistrar as AgentRegistrar
|
||||
|
||||
original_register_commands = AgentRegistrar.register_commands
|
||||
|
||||
def fail_recovered_claude_registration(self, agent_name, *args, **kwargs):
|
||||
if agent_name == "claude":
|
||||
raise PermissionError("denied")
|
||||
return original_register_commands(self, agent_name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentRegistrar, "register_commands", fail_recovered_claude_registration
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=True
|
||||
)
|
||||
|
||||
metadata = manager.registry.get(manifest.id)
|
||||
assert metadata["registered_commands"] == {}
|
||||
assert "speckit-early-ext-hello" in metadata["registered_skills"]
|
||||
assert "speckit-early-ext-world" in metadata["registered_skills"]
|
||||
|
||||
|
||||
# ===== Extension Skill Unregistration Tests =====
|
||||
|
||||
@@ -1137,7 +739,7 @@ class TestExtensionSkillEdgeCases:
|
||||
"""Corrupted init-options payloads should disable skill registration, not crash install."""
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text("[]", encoding="utf-8")
|
||||
opts_file.write_text("[]")
|
||||
_create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
@@ -1182,7 +784,7 @@ class TestExtensionSkillEdgeCases:
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
@@ -1201,7 +803,7 @@ class TestExtensionSkillEdgeCases:
|
||||
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
@@ -1217,10 +819,10 @@ class TestExtensionSkillEdgeCases:
|
||||
ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
manifest_a = manager.install_from_directory(
|
||||
ext_dir_a, "0.1.0", register_commands=False
|
||||
)
|
||||
manager.install_from_directory(
|
||||
manifest_b = manager.install_from_directory(
|
||||
ext_dir_b, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
@@ -1278,7 +880,7 @@ class TestExtensionSkillEdgeCases:
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
# Should not raise
|
||||
manager.install_from_directory(
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
|
||||
@@ -782,71 +782,6 @@ class TestExtensionManager:
|
||||
assert (ext_dir / "extension.yml").exists()
|
||||
assert (ext_dir / "commands" / "hello.md").exists()
|
||||
|
||||
def test_install_from_directory_explicitly_recovers_active_skills_dir(
|
||||
self, extension_dir, project_dir, monkeypatch
|
||||
):
|
||||
"""Extension install should explicitly request active skills-dir recovery."""
|
||||
captured = {}
|
||||
|
||||
def fake_register_all(
|
||||
self,
|
||||
manifest,
|
||||
extension_dir,
|
||||
project_root,
|
||||
link_outputs=False,
|
||||
create_missing_active_skills_dir=False,
|
||||
):
|
||||
captured["create_missing_active_skills_dir"] = (
|
||||
create_missing_active_skills_dir
|
||||
)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
CommandRegistrar,
|
||||
"register_commands_for_all_agents",
|
||||
fake_register_all,
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=True)
|
||||
|
||||
assert captured["create_missing_active_skills_dir"] is True
|
||||
|
||||
def test_command_registrar_default_does_not_recover_active_skills_dir(
|
||||
self, extension_dir, project_dir, monkeypatch
|
||||
):
|
||||
"""The extension wrapper should preserve the core registrar's conservative default."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_register_all(
|
||||
self,
|
||||
commands,
|
||||
source_id,
|
||||
source_dir,
|
||||
project_root,
|
||||
context_note=None,
|
||||
link_outputs=False,
|
||||
create_missing_active_skills_dir=False,
|
||||
):
|
||||
captured["create_missing_active_skills_dir"] = (
|
||||
create_missing_active_skills_dir
|
||||
)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentCommandRegistrar,
|
||||
"register_commands_for_all_agents",
|
||||
fake_register_all,
|
||||
)
|
||||
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
|
||||
|
||||
assert captured["create_missing_active_skills_dir"] is False
|
||||
|
||||
def test_install_duplicate(self, extension_dir, project_dir):
|
||||
"""Test installing already installed extension."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
@@ -858,102 +793,6 @@ class TestExtensionManager:
|
||||
with pytest.raises(ExtensionError, match="already installed"):
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
def test_install_force_reinstall(self, extension_dir, project_dir):
|
||||
"""Test force-reinstalling an already-installed extension."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
|
||||
# Install once
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
assert manager.registry.is_installed("test-ext")
|
||||
|
||||
# Force-reinstall
|
||||
manifest2 = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False, force=True
|
||||
)
|
||||
|
||||
assert manifest2.id == "test-ext"
|
||||
assert manager.registry.is_installed("test-ext")
|
||||
# Check extension directory was recreated
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert ext_dir.exists()
|
||||
assert (ext_dir / "extension.yml").exists()
|
||||
assert (ext_dir / "commands" / "hello.md").exists()
|
||||
|
||||
def test_install_force_config_preserved(self, extension_dir, project_dir):
|
||||
"""Test that config files are preserved when force-reinstalling."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
|
||||
# Install once
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
# Create a config file in the installed extension directory
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
|
||||
config_file = ext_dir / "test-ext-config.yml"
|
||||
config_file.write_text("test: config")
|
||||
|
||||
# Force-reinstall
|
||||
manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False, force=True
|
||||
)
|
||||
|
||||
# Config file should still exist after reinstall
|
||||
new_config = ext_dir / "test-ext-config.yml"
|
||||
assert new_config.exists()
|
||||
assert new_config.read_text() == "test: config"
|
||||
|
||||
def test_install_force_without_existing(self, extension_dir, project_dir):
|
||||
"""Test force-install when extension is NOT already installed (works normally)."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
|
||||
manifest = manager.install_from_directory(
|
||||
extension_dir, "0.1.0", register_commands=False, force=True
|
||||
)
|
||||
|
||||
assert manifest.id == "test-ext"
|
||||
assert manager.registry.is_installed("test-ext")
|
||||
|
||||
def test_install_zip_force_reinstall(self, extension_dir, project_dir):
|
||||
"""Test force-reinstalling from ZIP when already installed."""
|
||||
import zipfile
|
||||
import tempfile
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
|
||||
# Install once from directory
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
# Create a ZIP of the extension in a temp directory (not NamedTemporaryFile,
|
||||
# which can fail on Windows due to file locking).
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "test-ext.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for f in extension_dir.rglob("*"):
|
||||
if f.is_file():
|
||||
zf.write(f, f.relative_to(extension_dir))
|
||||
|
||||
# Force-reinstall from ZIP
|
||||
manifest = manager.install_from_zip(
|
||||
zip_path, "0.1.0", force=True
|
||||
)
|
||||
|
||||
assert manifest.id == "test-ext"
|
||||
assert manager.registry.is_installed("test-ext")
|
||||
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
|
||||
assert ext_dir.exists()
|
||||
|
||||
def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir):
|
||||
"""Test that duplicate install error message suggests --force."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
with pytest.raises(ExtensionError, match="--force"):
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir):
|
||||
"""Install should reject extension IDs that shadow core commands."""
|
||||
import yaml
|
||||
@@ -4949,26 +4788,6 @@ class TestHookInvocationRendering:
|
||||
assert execution["command"] == "speckit.tasks"
|
||||
assert execution["invocation"] == "$speckit-tasks"
|
||||
|
||||
def test_non_boolean_ai_skills_keeps_default_hook_invocation(self, project_dir):
|
||||
"""Corrupted truthy ai_skills values should not enable skill invocation."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(
|
||||
json.dumps({"ai": "codex", "ai_skills": "false"}), encoding="utf-8"
|
||||
)
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
execution = hook_executor.execute_hook(
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.tasks",
|
||||
"optional": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert execution["command"] == "speckit.tasks"
|
||||
assert execution["invocation"] == "/speckit.tasks"
|
||||
|
||||
def test_cline_hooks_render_hyphenated_invocation(self, project_dir):
|
||||
"""Cline projects should render /speckit-* invocations."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
@@ -5295,69 +5114,3 @@ $ARGUMENTS
|
||||
# Verify body references are still dotted for non-Cline
|
||||
assert "speckit.mock-ext.greet" in hello_body
|
||||
assert "speckit-mock-ext-greet" not in hello_body
|
||||
|
||||
|
||||
class TestExtensionForceCLI:
|
||||
"""CLI tests for `specify extension add --dev --force`."""
|
||||
|
||||
def _create_minimal_extension(self, base_dir: str | Path, ext_id: str = "test-ext") -> Path:
|
||||
"""Create a minimal extension directory with manifest."""
|
||||
import yaml
|
||||
|
||||
ext_dir = Path(base_dir) / ext_id
|
||||
ext_dir.mkdir(parents=True, exist_ok=True)
|
||||
(ext_dir / "commands").mkdir()
|
||||
|
||||
manifest = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": ext_id,
|
||||
"name": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": f"speckit.{ext_id}.hello",
|
||||
"file": "commands/hello.md",
|
||||
"description": "Test command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
(ext_dir / "extension.yml").write_text(yaml.dump(manifest))
|
||||
(ext_dir / "commands" / "hello.md").write_text(
|
||||
"---\ndescription: Test\n---\n\nHello $ARGUMENTS\n"
|
||||
)
|
||||
return ext_dir
|
||||
|
||||
def test_add_dev_force_reinstall(self, tmp_path):
|
||||
"""extension add --dev --force should reinstall without error."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
ext_src = self._create_minimal_extension(tmp_path)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
# First install
|
||||
result1 = runner.invoke(
|
||||
app, ["extension", "add", str(ext_src), "--dev"], catch_exceptions=False
|
||||
)
|
||||
assert result1.exit_code == 0, strip_ansi(result1.output)
|
||||
assert "installed" in strip_ansi(result1.output)
|
||||
|
||||
# Force reinstall
|
||||
result2 = runner.invoke(
|
||||
app, ["extension", "add", str(ext_src), "--dev", "--force"], catch_exceptions=False
|
||||
)
|
||||
assert result2.exit_code == 0, strip_ansi(result2.output)
|
||||
assert "installed" in strip_ansi(result2.output)
|
||||
|
||||
@@ -2255,51 +2255,6 @@ class TestInitOptions:
|
||||
assert loaded["ai"] == "claude"
|
||||
assert loaded["ai_skills"] is True
|
||||
|
||||
def test_save_and_load_available_from_init_options_module(self, project_dir):
|
||||
from specify_cli._init_options import load_init_options, save_init_options
|
||||
|
||||
opts = {"ai": "codex", "ai_skills": True, "script": "sh"}
|
||||
save_init_options(project_dir, opts)
|
||||
|
||||
assert load_init_options(project_dir) == opts
|
||||
|
||||
def test_save_uses_utf8_encoding(self, project_dir, monkeypatch):
|
||||
from specify_cli import save_init_options
|
||||
|
||||
original_write_text = Path.write_text
|
||||
seen: dict[str, str | None] = {}
|
||||
|
||||
def spy_write_text(path, data, *args, **kwargs):
|
||||
if path == project_dir / ".specify" / "init-options.json":
|
||||
seen["encoding"] = kwargs.get("encoding")
|
||||
return original_write_text(path, data, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "write_text", spy_write_text)
|
||||
|
||||
save_init_options(project_dir, {"label": "中文测试"})
|
||||
|
||||
assert seen["encoding"] == "utf-8"
|
||||
|
||||
def test_load_uses_utf8_encoding(self, project_dir, monkeypatch):
|
||||
from specify_cli import load_init_options
|
||||
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text('{"ai": "codex"}', encoding="utf-8")
|
||||
|
||||
original_read_text = Path.read_text
|
||||
seen: dict[str, str | None] = {}
|
||||
|
||||
def spy_read_text(path, *args, **kwargs):
|
||||
if path == opts_file:
|
||||
seen["encoding"] = kwargs.get("encoding")
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", spy_read_text)
|
||||
|
||||
assert load_init_options(project_dir) == {"ai": "codex"}
|
||||
assert seen["encoding"] == "utf-8"
|
||||
|
||||
def test_load_returns_empty_when_missing(self, project_dir):
|
||||
from specify_cli import load_init_options
|
||||
|
||||
@@ -2393,51 +2348,6 @@ class TestInitOptions:
|
||||
|
||||
assert load_init_options(project_dir) == {}
|
||||
|
||||
@pytest.mark.parametrize("payload", ["[]", '"value"', "42", "true", "null"])
|
||||
def test_load_returns_empty_on_non_object_json(self, project_dir, payload):
|
||||
from specify_cli import load_init_options
|
||||
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_text(payload, encoding="utf-8")
|
||||
|
||||
assert load_init_options(project_dir) == {}
|
||||
|
||||
def test_load_returns_empty_on_unicode_decode_error(self, project_dir, monkeypatch):
|
||||
from specify_cli import load_init_options
|
||||
|
||||
opts_file = project_dir / ".specify" / "init-options.json"
|
||||
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
opts_file.write_bytes(b"{}")
|
||||
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def raise_decode_error(path, *args, **kwargs):
|
||||
if path == opts_file:
|
||||
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", raise_decode_error)
|
||||
|
||||
assert load_init_options(project_dir) == {}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(True, True),
|
||||
(False, False),
|
||||
("true", False),
|
||||
("false", False),
|
||||
(1, False),
|
||||
(0, False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_is_ai_skills_enabled_requires_boolean_true(self, value, expected):
|
||||
from specify_cli._init_options import is_ai_skills_enabled
|
||||
|
||||
assert is_ai_skills_enabled({"ai_skills": value}) is expected
|
||||
|
||||
|
||||
class TestPresetSkills:
|
||||
"""Tests for preset skill registration and unregistration.
|
||||
|
||||
@@ -1,887 +0,0 @@
|
||||
"""Detection, argv assembly, and dry-run tests for `specify self upgrade`."""
|
||||
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import specify_cli
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
_InstallMethod,
|
||||
_assemble_installer_argv,
|
||||
_completed_process,
|
||||
_detect_install_method,
|
||||
mock_urlopen_response,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
|
||||
class TestDetectionUvTool:
|
||||
"""Tier-1 path-prefix detection for uv-tool installs."""
|
||||
|
||||
def test_posix_uv_tool_prefix_matches(self, uv_tool_argv0):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
assert signals.matched_tier == 1
|
||||
assert "uv/tools/specify-cli" in signals.matched_prefix.replace("\\", "/")
|
||||
|
||||
def test_detection_is_deterministic(self, uv_tool_argv0):
|
||||
a = _detect_install_method()
|
||||
b = _detect_install_method()
|
||||
assert a == b == _InstallMethod.UV_TOOL
|
||||
|
||||
def test_no_argv_match_falls_through_to_unsupported(self, unsupported_argv0):
|
||||
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
||||
"specify_cli._version._editable_marker_seen", return_value=False
|
||||
):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_include_signals_false_returns_bare_enum(self, uv_tool_argv0):
|
||||
result = _detect_install_method(include_signals=False)
|
||||
assert isinstance(result, _InstallMethod)
|
||||
|
||||
def test_bare_argv0_is_resolved_via_path_lookup(self, monkeypatch, tmp_path):
|
||||
if os.name == "nt":
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
|
||||
else:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
fake_dir = (
|
||||
tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
|
||||
)
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which",
|
||||
side_effect=lambda name: str(fake_specify) if name == "specify" else None,
|
||||
):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
|
||||
def test_prefix_match_does_not_accept_sibling_directory(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli2" / "bin"
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
monkeypatch.setattr("sys.argv", [str(fake_specify)])
|
||||
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
||||
"specify_cli._version._editable_marker_seen", return_value=False
|
||||
):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_uv_tool_when_registry_lists_exact_name(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
||||
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\nother-tool v1.2.3\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
assert signals.matched_tier == 3
|
||||
assert "uv tool list" in signals.installer_registries_consulted
|
||||
|
||||
def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
assert signals.installer_registries_consulted == ()
|
||||
|
||||
def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
missing_specify = tmp_path / "missing" / "specify"
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
|
||||
def fake_which(name):
|
||||
if name == "specify":
|
||||
return str(missing_specify)
|
||||
if name == "uv":
|
||||
return "uv"
|
||||
return None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
assert signals.matched_tier == 3
|
||||
assert "uv tool list" in signals.installer_registries_consulted
|
||||
|
||||
def test_missing_relative_argv0_falls_back_to_entrypoint_name_lookup(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
if os.name == "nt":
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
|
||||
else:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
fake_dir = (
|
||||
tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
|
||||
)
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
monkeypatch.setattr("sys.argv", ["./bin/specify"])
|
||||
|
||||
def fake_which(name):
|
||||
return str(fake_specify) if name == "specify" else None
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which):
|
||||
method = _detect_install_method()
|
||||
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
|
||||
def test_tier3_uv_tool_ignores_substring_false_positive(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="my-specify-cli-helper v0.1.0\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
venv_bin = tmp_path / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
fake_specify = venv_bin / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
fake_specify.chmod(0o755)
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
|
||||
def fake_which(name):
|
||||
if name == "specify":
|
||||
return str(fake_specify)
|
||||
if name == "uv":
|
||||
return "uv"
|
||||
return None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
assert signals.matched_tier is None
|
||||
assert signals.installer_registries_consulted == ()
|
||||
|
||||
|
||||
class TestPrefixExpansion:
|
||||
"""Path-prefix expansion edge cases."""
|
||||
|
||||
def test_literal_dollar_without_variable_name_is_preserved(self, tmp_path):
|
||||
prefix_path = tmp_path / "specify-$-cache" / "tools" / "specify-cli"
|
||||
prefix = str(prefix_path)
|
||||
|
||||
expanded = specify_cli._version._expand_prefix(prefix)
|
||||
|
||||
assert expanded == prefix_path.resolve()
|
||||
|
||||
def test_unresolved_posix_variable_is_rejected(self):
|
||||
assert specify_cli._version._expand_prefix("$SPECIFY_MISSING/specify-cli/") is None
|
||||
|
||||
def test_absolute_prefix_resolve_oserror_is_rejected(self, tmp_path):
|
||||
prefix = str(tmp_path / "specify-cli")
|
||||
|
||||
with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
||||
assert specify_cli._version._expand_prefix(prefix) is None
|
||||
|
||||
|
||||
class TestArgv0Resolution:
|
||||
"""Entrypoint path resolution edge cases."""
|
||||
|
||||
def test_absolute_argv0_resolve_oserror_returns_original_path(self, tmp_path):
|
||||
argv0 = tmp_path / "specify"
|
||||
|
||||
with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
||||
assert specify_cli._version._resolved_argv0_path(str(argv0)) == argv0
|
||||
|
||||
def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self):
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which", return_value="/broken/specify"
|
||||
), patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
||||
result = specify_cli._version._resolved_argv0_path("specify")
|
||||
|
||||
# Compare as Path objects: on Windows the same logical path renders
|
||||
# with backslashes, so a raw string compare against the POSIX form
|
||||
# would spuriously fail.
|
||||
assert result == Path("/broken/specify")
|
||||
|
||||
|
||||
class TestArgvAssemblyUvTool:
|
||||
"""uv-tool installer argv shape."""
|
||||
|
||||
def test_stable_tag_produces_expected_argv(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"):
|
||||
argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6")
|
||||
assert argv == [
|
||||
"uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
]
|
||||
|
||||
def test_dev_suffix_tag_embedded_literally(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"):
|
||||
argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0")
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv
|
||||
assert (
|
||||
"upgrade" not in argv
|
||||
) # never `uv tool upgrade` — does not accept --tag pinning
|
||||
|
||||
def test_missing_uv_returns_no_installer_argv(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value=None):
|
||||
assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None
|
||||
|
||||
|
||||
class TestBareUpgradeUvTool:
|
||||
"""uv-tool happy path, bare invocation."""
|
||||
|
||||
def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0), # installer
|
||||
_completed_process(0, stdout="specify 0.7.6\n"), # verify
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrading specify-cli 0.7.5 → v0.7.6 via uv tool:" in out
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
|
||||
assert mock_run.call_count == 2
|
||||
for call in mock_run.call_args_list:
|
||||
assert call.kwargs.get("shell", False) is False
|
||||
|
||||
def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ):
|
||||
# The single `invoke` represents the single user action — no prompt.
|
||||
# If a prompt existed, runner.invoke would hang waiting for input.
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestAlreadyLatestUvTool:
|
||||
"""already on latest, no installer launched."""
|
||||
|
||||
def test_already_latest_exits_zero_no_subprocess(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.6"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Already on latest release: v0.7.6" in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_trailing_zero_equivalent_version_reports_latest_not_newer(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
# Version("1.0") == Version("1.0.0") under packaging even though their
|
||||
# canonical strings differ. The no-op message must use Version equality
|
||||
# so this prints "Already on latest release", not "... or newer".
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="1.0"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Already on latest release: v1.0.0" in out
|
||||
assert "or newer" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_dev_build_ahead_of_release_reports_newer_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Already on latest release or newer: 0.7.7.dev0" in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_unparseable_current_version_does_not_false_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="release-main"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Already on latest release" not in out
|
||||
assert "Upgrading specify-cli release-main → v0.7.6 via uv tool:" in out
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_unparseable_resolved_target_fails_before_literal_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="release-main"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
out = strip_ansi(result.output)
|
||||
assert "not a comparable version" in out
|
||||
assert "release-main" not in out
|
||||
assert "Already on latest release" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_pinned_older_tag_still_runs_installer(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.6"
|
||||
):
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.5\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.5"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Already on latest release" not in out
|
||||
# A pinned older tag is a downgrade and must be labelled as such.
|
||||
assert "Downgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out
|
||||
assert "Upgrading specify-cli" not in out
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_pinned_rc_tag_uses_canonical_version_equality_for_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="1.0.0rc1"
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Already on requested release: v1.0.0-rc1" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestDryRunUvTool:
|
||||
"""--dry-run preview path + --dry-run combined with --tag."""
|
||||
|
||||
def test_dry_run_without_tag_resolves_network_but_no_subprocess(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Dry run — no changes will be made." in out
|
||||
assert "Detected install method: uv tool" in out
|
||||
assert "Current version: 0.7.5" in out
|
||||
assert "Target version: v0.7.6" in out
|
||||
assert "Command that would be executed:" in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ):
|
||||
# --dry-run with --tag must NOT hit the network.
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
), patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.8.0"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.8.0" in strip_ansi(result.output)
|
||||
mock_urlopen.assert_not_called()
|
||||
|
||||
def test_dry_run_rejects_unparseable_network_tag_before_preview(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response(
|
||||
{"tag_name": "v0.9.0;echo unsafe"}
|
||||
)
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
|
||||
out = strip_ansi(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "not a comparable version" in out
|
||||
assert "v0.9.0;echo unsafe" not in out
|
||||
assert "Command that would be executed:" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_dry_run_with_missing_uv_flags_unresolved_installer(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value=None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Command that would be executed: (installer uv not found on PATH)" in out
|
||||
assert "uv tool install" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 4 — User Story 2: `pipx` immediate upgrade (P2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestDetectionPipx:
|
||||
"""Pipx detection — tier 1 (path) and tier 3 (registry)."""
|
||||
|
||||
def test_posix_pipx_prefix_matches(self, pipx_argv0):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.PIPX
|
||||
assert signals.matched_tier == 1
|
||||
|
||||
def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
||||
|
||||
def fake_which(name):
|
||||
return "pipx" if name == "pipx" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout='{"venvs":{"specify-cli":{}}}',
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.PIPX
|
||||
assert signals.matched_tier == 3
|
||||
assert "pipx list --json" in signals.installer_registries_consulted
|
||||
|
||||
def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "pipx" if name == "pipx" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout='{"venvs":{"specify-cli":{}}}',
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_pipx_ignores_malformed_json_output(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "pipx" if name == "pipx" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="not json but mentions specify-cli",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
||||
|
||||
def fake_which(name):
|
||||
if name == "uv":
|
||||
return "uv"
|
||||
if name == "pipx":
|
||||
return "pipx"
|
||||
return None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout='{"venvs":{"specify-cli":{}}}',
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
assert signals.matched_tier is None
|
||||
assert "uv tool list" in signals.installer_registries_consulted
|
||||
assert "pipx list --json" in signals.installer_registries_consulted
|
||||
|
||||
|
||||
class TestEditableInstallMetadata:
|
||||
@pytest.mark.skipif(
|
||||
not hasattr(importlib.metadata, "InvalidMetadataError"),
|
||||
reason=(
|
||||
"importlib.metadata.InvalidMetadataError does not exist on this "
|
||||
"Python; _editable_direct_url_path only catches it when present, so "
|
||||
"fabricating it would exercise a path that cannot fire in production"
|
||||
),
|
||||
)
|
||||
def test_editable_marker_false_when_metadata_is_invalid(self):
|
||||
invalid_metadata_error = importlib.metadata.InvalidMetadataError
|
||||
|
||||
with patch(
|
||||
"importlib.metadata.distribution",
|
||||
side_effect=invalid_metadata_error("bad metadata"),
|
||||
):
|
||||
assert specify_cli._version._editable_marker_seen() is False
|
||||
assert specify_cli._version._source_checkout_path() is None
|
||||
|
||||
def test_direct_url_editable_install_marks_source_checkout(self, tmp_path):
|
||||
project_root = tmp_path / "spec-kit"
|
||||
project_root.mkdir()
|
||||
(project_root / ".git").mkdir()
|
||||
|
||||
class FakeDist:
|
||||
files = []
|
||||
|
||||
def read_text(self, name):
|
||||
if name == "direct_url.json":
|
||||
return json.dumps(
|
||||
{
|
||||
"dir_info": {"editable": True},
|
||||
"url": project_root.as_uri(),
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
def locate_file(self, file):
|
||||
return file
|
||||
|
||||
with patch("importlib.metadata.distribution", return_value=FakeDist()):
|
||||
assert specify_cli._version._editable_marker_seen() is True
|
||||
assert specify_cli._version._source_checkout_path() == project_root.resolve()
|
||||
|
||||
def test_editable_marker_false_without_explicit_editable_metadata(self, tmp_path):
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / ".git").mkdir()
|
||||
venv_file = repo_root / ".venv" / "lib" / "python3.13" / "site-packages" / "specify_cli.py"
|
||||
venv_file.parent.mkdir(parents=True)
|
||||
venv_file.write_text("# installed module\n")
|
||||
|
||||
class FakeDist:
|
||||
files = ["specify_cli.py"]
|
||||
|
||||
def read_text(self, name):
|
||||
return None
|
||||
|
||||
def locate_file(self, file):
|
||||
return venv_file
|
||||
|
||||
with patch("importlib.metadata.distribution", return_value=FakeDist()):
|
||||
assert specify_cli._version._editable_marker_seen() is False
|
||||
|
||||
|
||||
class TestTagValidationWhitespace:
|
||||
def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.8.0\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", " v0.8.0 "])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "v0.8.0" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestArgvAssemblyPipx:
|
||||
"""pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`."""
|
||||
|
||||
def test_pipx_argv_uses_install_force_positional_not_upgrade(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value="pipx"):
|
||||
argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6")
|
||||
assert argv == [
|
||||
"pipx",
|
||||
"install",
|
||||
"--force",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
]
|
||||
assert "upgrade" not in argv # pipx upgrade does not accept arbitrary refs
|
||||
assert "--spec" not in argv # pipx 1.5+ dropped the --spec flag
|
||||
|
||||
def test_missing_pipx_returns_no_installer_argv(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value=None):
|
||||
assert _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") is None
|
||||
|
||||
|
||||
class TestBareUpgradePipx:
|
||||
"""pipx happy path."""
|
||||
|
||||
def test_happy_path(self, pipx_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="pipx"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "via pipx:" in out
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
|
||||
|
||||
|
||||
class TestDetectionShortCircuit:
|
||||
"""Tier-1 path-prefix matches short-circuit before registry checks."""
|
||||
|
||||
def test_pipx_argv0_prefix_short_circuits_before_registry_checks(
|
||||
self,
|
||||
pipx_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="/usr/bin/X"), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.PIPX
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestDryRunPipx:
|
||||
def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="pipx"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
assert "Detected install method: pipx" in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
@@ -1,542 +0,0 @@
|
||||
"""Installer execution, verification, and error-path tests for `specify self upgrade`."""
|
||||
|
||||
import errno
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
_completed_process,
|
||||
mock_urlopen_response,
|
||||
requires_posix,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 6 — User Story 4: failure recovery (P2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestInstallerMissing:
|
||||
"""Installer disappeared between detection and run → exit 3."""
|
||||
|
||||
def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ):
|
||||
which_results = {"specify": "/usr/local/bin/specify"}
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n)
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert "Installer uv not found on PATH; reinstall it and retry." in out
|
||||
assert "Upgrading specify-cli" not in out
|
||||
|
||||
def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ):
|
||||
which_results = {}
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n)
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
assert "Installer pipx not found on PATH" in strip_ansi(result.output)
|
||||
|
||||
def test_absolute_installer_path_does_not_require_path_lookup(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o755)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._verify_upgrade", return_value="0.7.6"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(0)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@requires_posix
|
||||
def test_relative_installer_path_does_not_require_path_lookup(
|
||||
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "uv"
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o755)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._verify_upgrade", return_value="0.7.6"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"./uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(0)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert mock_run.call_args.args[0][0] == "./uv"
|
||||
|
||||
@requires_posix
|
||||
def test_relative_installer_path_missing_gets_path_specific_message(
|
||||
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"./uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
"Installer path ./uv no longer exists; reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
assert "not found on PATH" not in strip_ansi(result.output)
|
||||
|
||||
def test_resolved_absolute_installer_removed_before_exec_gets_missing_path_message(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o755)
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
fake_uv.unlink()
|
||||
raise FileNotFoundError(str(fake_uv))
|
||||
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which",
|
||||
side_effect=lambda name: str(fake_uv) if name == "uv" else None,
|
||||
), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
f"Installer path {fake_uv} no longer exists; reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
|
||||
def test_absolute_installer_path_not_executable_gets_specific_message(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o644)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.os.access", return_value=False), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
f"Installer path {fake_uv} is not an executable file; fix the path or reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
|
||||
@requires_posix
|
||||
def test_relative_installer_path_not_executable_gets_path_specific_message(
|
||||
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "uv"
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o644)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.os.access", return_value=False), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"./uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
out = strip_ansi(result.output)
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
"Installer path ./uv is not an executable file; fix the path or reinstall it and retry."
|
||||
in out
|
||||
)
|
||||
assert "Installer ./uv is not executable" not in out
|
||||
|
||||
def test_real_installer_exit_126_is_not_treated_as_invalid_path(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(126)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 126
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade failed. Installer exit code: 126." in out
|
||||
assert "not an executable file" not in out
|
||||
|
||||
def test_absolute_installer_path_missing_gets_path_specific_message(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "missing-installer" / "uv"
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
f"Installer path {fake_uv} no longer exists; reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_exec_oserror_is_treated_as_invalid_installer(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
fake_uv.chmod(0o755)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch(
|
||||
"specify_cli._version.subprocess.run",
|
||||
side_effect=PermissionError("Permission denied"),
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert f"Installer path {fake_uv} is not an executable file" in out
|
||||
assert "not found on PATH" not in out
|
||||
|
||||
def test_bare_invalid_installer_message_does_not_call_it_a_path(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch(
|
||||
"specify_cli._version.subprocess.run",
|
||||
side_effect=PermissionError("Permission denied"),
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert "Installer uv is not executable" in out
|
||||
assert "Installer path uv" not in out
|
||||
|
||||
def test_exec_oserror_errno_is_treated_as_invalid_installer(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
fake_uv.chmod(0o755)
|
||||
invalid_error = OSError(errno.ENOEXEC, "Exec format error")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch("specify_cli._version.subprocess.run", side_effect=invalid_error):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert f"Installer path {fake_uv} is not an executable file" in out
|
||||
assert "not found on PATH" not in out
|
||||
|
||||
def test_transient_exec_oserror_is_not_treated_as_invalid_installer(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
fake_uv.chmod(0o755)
|
||||
transient_error = OSError(errno.EMFILE, "Too many open files")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch("specify_cli._version.subprocess.run", side_effect=transient_error):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
# Transient/unknown OSErrors are re-raised rather than mapped to the
|
||||
# invalid-installer exit 3, so the CLI surfaces them as an uncaught
|
||||
# error: exit code 1 with the original OSError preserved.
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, OSError)
|
||||
|
||||
|
||||
class TestInstallerFailed:
|
||||
"""Installer non-zero exit → propagate code, print rollback hint."""
|
||||
|
||||
def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)] # installer fails
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade failed. Installer exit code: 2." in out
|
||||
assert "Try again or run the command manually:" in out
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.6" in out
|
||||
assert (
|
||||
"To pin back to the previous version: "
|
||||
"uv tool install specify-cli --force --from "
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.5"
|
||||
) in out
|
||||
# No verification attempted after a failed installer run.
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(127)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 127
|
||||
|
||||
def test_installer_timeout_prints_timeout_specific_message(
|
||||
self, uv_tool_argv0, clean_environ, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
subprocess.TimeoutExpired(cmd=["uv"], timeout=12)
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 124
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade timed out while waiting for the installer subprocess." in out
|
||||
assert "SPECIFY_UPGRADE_TIMEOUT_SECS=12" in out
|
||||
|
||||
def test_non_finite_timeout_warns_and_runs_without_timeout(
|
||||
self, uv_tool_argv0, clean_environ, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Ignoring invalid SPECIFY_UPGRADE_TIMEOUT_SECS='nan'" in strip_ansi(
|
||||
result.output
|
||||
)
|
||||
assert mock_run.call_args_list[0].kwargs["timeout"] is None
|
||||
|
||||
def test_real_installer_exit_124_is_not_treated_as_timeout(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(124)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 124
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade failed. Installer exit code: 124." in out
|
||||
assert "Upgrade timed out while waiting for the installer subprocess." not in out
|
||||
|
||||
def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="pipx"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert (
|
||||
"To pin back to the previous version: pipx install --force "
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.5"
|
||||
) in out
|
||||
|
||||
def test_rollback_hint_accepts_normalizable_stable_snapshot(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="v0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert (
|
||||
"To pin back to the previous version: uv tool install specify-cli --force "
|
||||
"--from git+https://github.com/github/spec-kit.git@v0.7.5"
|
||||
) in out
|
||||
assert "Previous version was not an exact stable release tag" not in out
|
||||
|
||||
def test_prerelease_failure_degrades_rollback_hint_to_releases_page(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="1.0.0rc1"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"})
|
||||
mock_run.side_effect = [_completed_process(2)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Previous version was not an exact stable release tag" in out
|
||||
assert "https://github.com/github/spec-kit/releases" in out
|
||||
assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out
|
||||
@@ -1,184 +0,0 @@
|
||||
"""Non-upgradable path guidance tests for `specify self upgrade`."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
mock_urlopen_response,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 5 — User Story 3: non-upgradable path guidance (P3)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestUvxEphemeral:
|
||||
"""uvx ephemeral path emits exact one-liner, no installer call."""
|
||||
|
||||
def test_uvx_argv0_prints_exact_one_liner_and_exits_zero(
|
||||
self,
|
||||
uvx_ephemeral_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
expected = (
|
||||
"Running via uvx (ephemeral); the next uvx invocation already "
|
||||
"resolves to latest — no upgrade action needed."
|
||||
)
|
||||
assert expected in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_offline_still_exits_zero_without_tag_resolution(
|
||||
self,
|
||||
uvx_ephemeral_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=AssertionError("non-upgradable uvx path must not hit network"),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
assert "uvx (ephemeral)" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestSourceCheckout:
|
||||
"""Editable install path emits git pull guidance."""
|
||||
|
||||
def test_source_checkout_prints_git_pull_guidance(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
tmp_path,
|
||||
clean_environ,
|
||||
):
|
||||
fake_tree = tmp_path / "worktree"
|
||||
fake_tree.mkdir()
|
||||
(fake_tree / ".git").mkdir()
|
||||
|
||||
with patch("specify_cli._version._editable_marker_seen", return_value=True), patch(
|
||||
"specify_cli._version._source_checkout_path", return_value=fake_tree
|
||||
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert f"Running from a source checkout at {fake_tree}" in out
|
||||
assert "git pull" in out
|
||||
assert "pip install -e ." in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_source_checkout_without_path_mentions_checkout_directory(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version._editable_marker_seen", return_value=True), patch(
|
||||
"specify_cli._version._source_checkout_path", return_value=None
|
||||
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
out = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "checkout path could not be detected" in out
|
||||
assert "from your checkout directory" in out
|
||||
assert "(path unavailable)" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
|
||||
class TestUnsupported:
|
||||
"""Unsupported path enumerates manual reinstall commands."""
|
||||
|
||||
def test_unsupported_prints_both_reinstall_commands(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
|
||||
"specify_cli._version.shutil.which", return_value=None
|
||||
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Could not identify your install method automatically" in out
|
||||
assert (
|
||||
"uv tool install specify-cli --force --from "
|
||||
"git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
||||
) in out
|
||||
assert (
|
||||
"pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
||||
in out
|
||||
)
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_unsupported_offline_degrades_to_placeholder_manual_commands(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
|
||||
"specify_cli._version.shutil.which", return_value=None
|
||||
), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=AssertionError("unsupported guidance should not require network"),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Could not identify your install method automatically" in out
|
||||
assert (
|
||||
"uv tool install specify-cli --force --from "
|
||||
"git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
||||
) in out
|
||||
assert (
|
||||
"pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
||||
in out
|
||||
)
|
||||
|
||||
|
||||
class TestDryRunNonUpgradablePaths:
|
||||
"""--dry-run on non-upgradable paths emits guidance, not preview."""
|
||||
|
||||
def test_dry_run_on_uvx_ephemeral_emits_guidance_not_preview(
|
||||
self,
|
||||
uvx_ephemeral_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Dry run — no changes will be made." not in out
|
||||
assert "uvx (ephemeral)" in out
|
||||
|
||||
def test_dry_run_on_unsupported_emits_manual_commands(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
|
||||
"specify_cli._version.shutil.which", return_value=None
|
||||
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen:
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
assert "Could not identify your install method" in strip_ansi(result.output)
|
||||
@@ -1,649 +0,0 @@
|
||||
"""Verification, resolution, and validation tests for `specify self upgrade`."""
|
||||
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import specify_cli
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
SENTINEL_GH_TOKEN,
|
||||
SENTINEL_GITHUB_TOKEN,
|
||||
_InstallMethod,
|
||||
_UpgradePlan,
|
||||
_completed_process,
|
||||
_verify_upgrade,
|
||||
mock_urlopen_response,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 6 — User Story 4: failure recovery (P2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestVerificationMismatch:
|
||||
"""Installer says 0 but the binary is still the old version → exit 2."""
|
||||
|
||||
def test_installer_ok_but_verify_returns_old_version(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0), # installer OK
|
||||
_completed_process(0, stdout="specify 0.7.5\n"), # verify: OLD!
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Verification failed" in out
|
||||
assert "resolves to 0.7.5 (expected v0.7.6)" in out
|
||||
assert "The new version may take effect on your next invocation." in out
|
||||
|
||||
def test_verify_nonzero_exit_is_not_treated_as_success(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(1, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Verification failed" in out
|
||||
assert "(unknown) (expected v0.7.6)" in out
|
||||
|
||||
def test_verify_accepts_pep440_equivalent_rc_version(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.9.0"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 1.0.0rc1\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output)
|
||||
|
||||
def test_verify_accepts_specify_cli_binary_name_in_version_output(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify-cli version 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output)
|
||||
|
||||
def test_verify_accepts_capitalized_binary_name_in_version_output(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="Specify, version 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output)
|
||||
|
||||
def test_verify_rejects_output_without_parseable_version(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify version unknown\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Verification failed" in out
|
||||
assert "(unknown) (expected v0.7.6)" in out
|
||||
|
||||
def test_verify_uses_current_entrypoint_when_not_on_path(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
assert uv_tool_argv0.exists()
|
||||
assert uv_tool_argv0.is_file()
|
||||
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.os.access", return_value=True
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == str(uv_tool_argv0)
|
||||
assert mock_run.call_args.kwargs["timeout"] == specify_cli._version._VERIFY_TIMEOUT_SECS
|
||||
|
||||
def test_verify_falls_back_to_path_when_current_entrypoint_is_not_executable(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which",
|
||||
side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None,
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version.os.access", return_value=False
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify"
|
||||
|
||||
def test_verify_ignores_python_entrypoint_and_falls_back_to_specify(
|
||||
self,
|
||||
clean_environ,
|
||||
tmp_path,
|
||||
):
|
||||
fake_python = tmp_path / "python3"
|
||||
fake_python.write_text("#!/bin/sh\n")
|
||||
fake_python.chmod(0o755)
|
||||
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version.sys.argv", [str(fake_python)]
|
||||
), patch(
|
||||
"specify_cli._version.os.access", return_value=True
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify"
|
||||
|
||||
def test_verify_accepts_specify_cli_named_current_entrypoint(
|
||||
self,
|
||||
clean_environ,
|
||||
tmp_path,
|
||||
):
|
||||
fake_specify_cli = tmp_path / "specify-cli"
|
||||
fake_specify_cli.write_text("#!/bin/sh\n")
|
||||
fake_specify_cli.chmod(0o755)
|
||||
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch("specify_cli._version.sys.argv", [str(fake_specify_cli)]), patch(
|
||||
"specify_cli._version.os.access", return_value=True
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == str(fake_specify_cli)
|
||||
|
||||
|
||||
class TestResolutionFailures:
|
||||
"""Pre-installer resolution failure → exit 1, reusing the resolver category strings."""
|
||||
|
||||
def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ):
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("nope"),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert "Upgrade aborted: offline or timeout" in strip_ansi(result.output)
|
||||
|
||||
def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://api.github.com",
|
||||
code=403,
|
||||
msg="rate limited",
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert (
|
||||
"Upgrade aborted: rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
|
||||
def test_http_500_exits_1(self, uv_tool_argv0, clean_environ):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://api.github.com",
|
||||
code=500,
|
||||
msg="srv err",
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code, expected",
|
||||
[
|
||||
# 429 (Too Many Requests / secondary rate limit) gets the same
|
||||
# actionable token hint as 403; other statuses surface verbatim.
|
||||
(
|
||||
429,
|
||||
"Upgrade aborted: rate limited (configure ~/.specify/auth.json "
|
||||
"with a GitHub token)",
|
||||
),
|
||||
(404, "Upgrade aborted: HTTP 404"),
|
||||
(502, "Upgrade aborted: HTTP 502"),
|
||||
],
|
||||
)
|
||||
def test_http_error_categorization(
|
||||
self, code, expected, uv_tool_argv0, clean_environ
|
||||
):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://api.github.com",
|
||||
code=code,
|
||||
msg="err",
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert expected in strip_ansi(result.output)
|
||||
|
||||
def test_unparseable_resolved_release_tag_exits_1_without_traceback(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
out = strip_ansi(result.output)
|
||||
assert "resolved release tag is not a comparable version" in out
|
||||
assert "release-main" not in out
|
||||
assert "Traceback" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
|
||||
class TestTagValidation:
|
||||
"""--tag regex enforcement."""
|
||||
|
||||
def test_valid_stable_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.7.6"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.8.0.dev0" in strip_ansi(result.output)
|
||||
|
||||
def test_valid_rc_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="1.0.0b1"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--tag", "v1.0.0-beta.1"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Already on requested release: v1.0.0-beta.1" in strip_ansi(
|
||||
result.output
|
||||
)
|
||||
|
||||
def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.8.0+build.42" in strip_ansi(result.output)
|
||||
|
||||
def test_uppercase_v_prefix_is_folded_to_lowercase(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
# A pasted uppercase `V` prefix is accepted and normalized to `v` so
|
||||
# the git ref matches the canonical lowercase release tag.
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "V0.7.6"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.7.6" in strip_ansi(result.output)
|
||||
|
||||
def test_valid_prerelease_with_build_metadata_tag(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
# Prerelease and build-metadata suffixes compose (PEP 440 / semver).
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1+build.42"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v1.0.0-rc1+build.42" in strip_ansi(result.output)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_tag",
|
||||
[
|
||||
"latest",
|
||||
"0.7.5",
|
||||
"main",
|
||||
"v7",
|
||||
"",
|
||||
"v1.2.3abc",
|
||||
"v1.2.3...",
|
||||
"v1.2.3++",
|
||||
"v\uff11.2.3",
|
||||
"v1.\u0662.3",
|
||||
],
|
||||
)
|
||||
def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ):
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag])
|
||||
assert result.exit_code == 1
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output
|
||||
|
||||
|
||||
class TestUnknownCurrent:
|
||||
"""'unknown' current version renders literally in notice and success message."""
|
||||
|
||||
def test_unknown_current_renders_literal_in_notice(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="unknown"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrading specify-cli unknown → v0.7.6 via uv tool:" in out
|
||||
assert "Upgraded specify-cli: unknown → 0.7.6" in out
|
||||
|
||||
def test_unknown_current_rollback_hint_degrades(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="unknown"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)] # installer fails
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Could not determine the previous version" in out
|
||||
assert "https://github.com/github/spec-kit/releases" in out
|
||||
|
||||
|
||||
class TestTokenScrubbing:
|
||||
"""GH_TOKEN / GITHUB_TOKEN are stripped from every child env."""
|
||||
|
||||
def test_env_passed_to_subprocess_has_no_github_tokens(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
response = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener"
|
||||
) as mock_build_opener, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = response
|
||||
mock_build_opener.return_value.open.return_value = response
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert mock_run.call_count >= 1
|
||||
for call in mock_run.call_args_list:
|
||||
env_kwarg = call.kwargs.get("env") or {}
|
||||
assert "GH_TOKEN" not in env_kwarg, f"env leaked GH_TOKEN: {env_kwarg!r}"
|
||||
assert "GITHUB_TOKEN" not in env_kwarg
|
||||
for v in env_kwarg.values():
|
||||
assert SENTINEL_GH_TOKEN not in v
|
||||
assert SENTINEL_GITHUB_TOKEN not in v
|
||||
|
||||
def test_env_scrubbing_is_case_insensitive(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN)
|
||||
response = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener"
|
||||
) as mock_build_opener, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = response
|
||||
mock_build_opener.return_value.open.return_value = response
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert mock_run.call_count >= 1
|
||||
for call in mock_run.call_args_list:
|
||||
env_kwarg = call.kwargs.get("env") or {}
|
||||
assert "gh_token" not in env_kwarg
|
||||
assert "GitHub_Token" not in env_kwarg
|
||||
for v in env_kwarg.values():
|
||||
assert SENTINEL_GH_TOKEN not in v
|
||||
assert SENTINEL_GITHUB_TOKEN not in v
|
||||
|
||||
def test_env_scrubbing_removes_github_token_variants(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_PAT", "gh-pat")
|
||||
monkeypatch.setenv("GH_TOKEN_FILE", "gh-token-file")
|
||||
monkeypatch.setenv("GH_ENTERPRISE_TOKEN", "enterprise-gh")
|
||||
monkeypatch.setenv("GH_ENTERPRISE_SECRET", "enterprise-secret")
|
||||
monkeypatch.setenv("GH_ENTERPRISE_PRIVATE_KEY", "enterprise-key")
|
||||
monkeypatch.setenv("GITHUB_PAT", "github-pat")
|
||||
monkeypatch.setenv("GITHUB_TOKEN_PATH", "github-token-path")
|
||||
monkeypatch.setenv("GITHUB_ENTERPRISE_TOKEN", "enterprise-github")
|
||||
monkeypatch.setenv("GITHUB_API_TOKEN", "api-token")
|
||||
monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key")
|
||||
monkeypatch.setenv("GITHUB_OAUTH_CLIENT_SECRET", "oauth-secret")
|
||||
monkeypatch.setenv("HOMEBREW_GITHUB_API_TOKEN", "homebrew-token")
|
||||
monkeypatch.setenv("NOTGITHUB_TOKEN", "not-github-kept")
|
||||
monkeypatch.setenv("GHOST_API_TOKEN", "ghost-kept")
|
||||
monkeypatch.setenv("GHIDRA_API_KEY", "ghidra-kept")
|
||||
monkeypatch.setenv("UNRELATED_TOKEN", "kept")
|
||||
|
||||
env = specify_cli._version._scrubbed_env()
|
||||
|
||||
assert "GH_PAT" not in env
|
||||
assert "GH_TOKEN_FILE" not in env
|
||||
assert "GH_ENTERPRISE_TOKEN" not in env
|
||||
assert "GH_ENTERPRISE_SECRET" not in env
|
||||
assert "GH_ENTERPRISE_PRIVATE_KEY" not in env
|
||||
assert "GITHUB_PAT" not in env
|
||||
assert "GITHUB_TOKEN_PATH" not in env
|
||||
assert "GITHUB_ENTERPRISE_TOKEN" not in env
|
||||
assert "GITHUB_API_TOKEN" not in env
|
||||
assert "GITHUB_APP_PRIVATE_KEY" not in env
|
||||
assert "GITHUB_OAUTH_CLIENT_SECRET" not in env
|
||||
assert "HOMEBREW_GITHUB_API_TOKEN" not in env
|
||||
assert env["NOTGITHUB_TOKEN"] == "not-github-kept"
|
||||
assert env["GHOST_API_TOKEN"] == "ghost-kept"
|
||||
assert env["GHIDRA_API_KEY"] == "ghidra-kept"
|
||||
assert env["UNRELATED_TOKEN"] == "kept"
|
||||
|
||||
def test_env_scrubbing_strips_noncredential_github_vars_by_design(
|
||||
self, monkeypatch
|
||||
):
|
||||
# The scrub is intentionally broad: every GH_/GITHUB_-prefixed name is
|
||||
# removed from the installer subprocess env, including non-credential
|
||||
# context vars. This is a deliberate fail-safe so credential-adjacent
|
||||
# names that lack a recognized suffix (e.g. GH_TOKEN_FILE,
|
||||
# GITHUB_TOKEN_PATH, asserted above) can never leak. The installer
|
||||
# (`uv tool install` / `pipx install` of a public package) does not
|
||||
# consume routing/context vars like GITHUB_REPOSITORY, so nothing the
|
||||
# subprocess needs is lost by stripping them.
|
||||
monkeypatch.setenv("GH_HOST", "github.example.com")
|
||||
monkeypatch.setenv("GH_CONFIG_DIR", "/home/u/.config/gh")
|
||||
monkeypatch.setenv("GITHUB_REPOSITORY", "github/spec-kit")
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", "/home/runner/work")
|
||||
monkeypatch.setenv("GITHUB_USER", "octocat")
|
||||
|
||||
env = specify_cli._version._scrubbed_env()
|
||||
|
||||
assert "GH_HOST" not in env
|
||||
assert "GH_CONFIG_DIR" not in env
|
||||
assert "GITHUB_REPOSITORY" not in env
|
||||
assert "GITHUB_WORKSPACE" not in env
|
||||
assert "GITHUB_USER" not in env
|
||||
@@ -13,10 +13,8 @@ from tests.conftest import requires_bash
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh"
|
||||
CHECK_PREREQ_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
@@ -32,7 +30,6 @@ def _install_bash_scripts(repo: Path) -> None:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, d / "common.sh")
|
||||
shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh")
|
||||
shutil.copy(CHECK_PREREQ_SH, d / "check-prerequisites.sh")
|
||||
|
||||
|
||||
def _install_ps_scripts(repo: Path) -> None:
|
||||
@@ -40,7 +37,6 @@ def _install_ps_scripts(repo: Path) -> None:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, d / "common.ps1")
|
||||
shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1")
|
||||
shutil.copy(CHECK_PREREQ_PS, d / "check-prerequisites.ps1")
|
||||
|
||||
|
||||
def _install_core_tasks_template(repo: Path) -> None:
|
||||
@@ -61,25 +57,6 @@ def _minimal_feature(repo: Path) -> Path:
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
return feat
|
||||
|
||||
|
||||
def _write_integration_state(repo: Path, integration: str = "claude", separator: str = "-") -> None:
|
||||
specify_dir = repo / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
state = {
|
||||
"integration": integration,
|
||||
"default_integration": integration,
|
||||
"installed_integrations": [integration],
|
||||
"integration_settings": {
|
||||
integration: {
|
||||
"invoke_separator": separator,
|
||||
},
|
||||
},
|
||||
}
|
||||
(specify_dir / "integration.json").write_text(
|
||||
json.dumps(state),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
@@ -94,38 +71,6 @@ def _clean_env() -> dict[str, str]:
|
||||
return env
|
||||
|
||||
|
||||
def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
|
||||
script = repo / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
return subprocess.run(
|
||||
["bash", "-c", 'source "$1"; format_speckit_command "$2" "$PWD"', "bash", str(script), command_name],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def _run_powershell_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
|
||||
script = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
return subprocess.run(
|
||||
[
|
||||
exe,
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
'& { param($common, $commandName) . $common; Format-SpecKitCommand -CommandName $commandName -RepoRoot (Get-Location).Path }',
|
||||
str(script),
|
||||
command_name,
|
||||
],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def _git_init(repo: Path) -> None:
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
@@ -178,7 +123,7 @@ def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
|
||||
setup-tasks.sh --json should exit 0 and return an absolute, existing
|
||||
TASKS_TEMPLATE path pointing to the core template.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
@@ -205,7 +150,7 @@ def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
|
||||
When an override exists at .specify/templates/overrides/tasks-template.md,
|
||||
setup-tasks.sh --json must return the override path, not the core path.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
# Create the override
|
||||
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
|
||||
@@ -242,7 +187,7 @@ def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
|
||||
When an extension template exists, setup-tasks.sh --json must resolve
|
||||
tasks-template.md from the extension before falling back to the core path.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
|
||||
extension_dir = (
|
||||
@@ -280,7 +225,7 @@ def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
|
||||
When both preset and extension templates exist, setup-tasks.sh --json must
|
||||
resolve the preset path because presets outrank extensions.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
|
||||
extension_dir = (
|
||||
@@ -324,7 +269,7 @@ def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
|
||||
When two presets both provide tasks-template.md, the one listed first in
|
||||
.specify/presets/.registry wins.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
# resolve_template reads .specify/presets/.registry as a JSON object with a
|
||||
# "presets" map where each entry has a numeric "priority" (lower = higher
|
||||
@@ -384,7 +329,7 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
|
||||
When tasks-template.md is absent from all locations, setup-tasks.sh must
|
||||
exit non-zero and print a helpful ERROR message to stderr.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
# Remove the core template so no template exists anywhere
|
||||
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
|
||||
@@ -400,138 +345,12 @@ def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "ERROR" in result.stderr
|
||||
assert "tasks-template" in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_defaults_to_dot_without_integration_json(tasks_repo: Path) -> None:
|
||||
integration_json = tasks_repo / ".specify" / "integration.json"
|
||||
if integration_json.exists():
|
||||
integration_json.unlink()
|
||||
|
||||
result = _run_bash_format_command(tasks_repo, "plan")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.plan"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_rejects_invalid_invoke_separator(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "/")
|
||||
|
||||
result = _run_bash_format_command(tasks_repo, "plan")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.plan"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_normalizes_mixed_separators(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "copilot", ".")
|
||||
|
||||
result = _run_bash_format_command(tasks_repo, "/speckit-git.commit")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.git.commit"
|
||||
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
|
||||
result = _run_bash_format_command(tasks_repo, "speckit.git-commit")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit-git-commit"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "copilot", ".")
|
||||
|
||||
result = _run_bash_format_command(tasks_repo, "speckit.jira.sync-status")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.jira.sync-status"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh"
|
||||
dot_state = {
|
||||
"integration": "copilot",
|
||||
"default_integration": "copilot",
|
||||
"installed_integrations": ["copilot"],
|
||||
"integration_settings": {"copilot": {"invoke_separator": "."}},
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
'source "$1"; format_speckit_command plan "$PWD"; printf "%s" "$2" > .specify/integration.json; format_speckit_command tasks "$PWD"',
|
||||
"bash",
|
||||
str(script),
|
||||
json.dumps(dot_state),
|
||||
],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.splitlines() == ["/speckit-plan", "/speckit-tasks"]
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Run /speckit-plan first" in result.stderr
|
||||
assert "/speckit.plan" not in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_check_prerequisites_bash_uses_invoke_separator_in_tasks_hint(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--require-tasks"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Run /speckit-tasks first" in result.stderr
|
||||
assert "/speckit.tasks" not in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
|
||||
tasks_repo: Path,
|
||||
@@ -594,10 +413,11 @@ def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# POWERSHELL TESTS
|
||||
# ===========================================================================
|
||||
@@ -609,7 +429,7 @@ def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
|
||||
setup-tasks.ps1 -Json should exit 0 and return an absolute, existing
|
||||
TASKS_TEMPLATE path.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
@@ -637,7 +457,7 @@ def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
|
||||
When an override exists at .specify/templates/overrides/tasks-template.md,
|
||||
setup-tasks.ps1 -Json must return the override path, not the core path.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
|
||||
overrides_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -673,7 +493,7 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
When tasks-template.md is absent from all locations, setup-tasks.ps1 must
|
||||
exit non-zero and write a helpful error to stderr.
|
||||
"""
|
||||
_minimal_feature(tasks_repo)
|
||||
feat = _minimal_feature(tasks_repo)
|
||||
|
||||
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
|
||||
core.unlink()
|
||||
@@ -694,87 +514,6 @@ def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
|
||||
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_powershell_command_hint_normalizes_mixed_separators(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
_write_integration_state(tasks_repo, "copilot", ".")
|
||||
|
||||
result = _run_powershell_format_command(tasks_repo, "/speckit-git.commit")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.git.commit"
|
||||
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
|
||||
result = _run_powershell_format_command(tasks_repo, "speckit.git-commit")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit-git-commit"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_powershell_command_hint_preserves_hyphens_inside_segments(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
_write_integration_state(tasks_repo, "copilot", ".")
|
||||
|
||||
result = _run_powershell_format_command(tasks_repo, "speckit.jira.sync-status")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/speckit.jira.sync-status"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
feat = tasks_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
output = result.stderr + result.stdout
|
||||
assert result.returncode != 0
|
||||
assert "Run /speckit-plan first" in output
|
||||
assert "/speckit.plan" not in output
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
|
||||
tasks_repo: Path,
|
||||
) -> None:
|
||||
_write_integration_state(tasks_repo, "claude", "-")
|
||||
_minimal_feature(tasks_repo)
|
||||
|
||||
script = tasks_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-RequireTasks"],
|
||||
cwd=tasks_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
|
||||
output = result.stderr + result.stdout
|
||||
assert result.returncode != 0
|
||||
assert "Run /speckit-tasks first" in output
|
||||
assert "/speckit.tasks" not in output
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
|
||||
def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
|
||||
tasks_repo: Path,
|
||||
@@ -842,3 +581,4 @@ def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Not on a feature branch" in result.stderr
|
||||
|
||||
@@ -923,7 +923,7 @@ class TestDryRun:
|
||||
assert re.match(r"^\d{8}-\d{6}-ts-feat$", branch), f"unexpected: {branch}"
|
||||
# Verify no side effects
|
||||
branches = subprocess.run(
|
||||
["git", "branch", "--list", "*ts-feat*"],
|
||||
["git", "branch", "--list", f"*ts-feat*"],
|
||||
cwd=git_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Tests for the `specify self` sub-app (`self check` and `self upgrade`).
|
||||
|
||||
Network isolation contract (SC-004 / FR-014): every test that exercises
|
||||
`specify self check` or `_fetch_latest_release_tag()` MUST mock the outbound
|
||||
urllib path it expects (`urlopen` for unauthenticated requests, `build_opener`
|
||||
for authenticated requests) so no real outbound call ever reaches api.github.com.
|
||||
Tests for non-network `self upgrade` behavior should keep that contract explicit
|
||||
with local mocks. Run this module under `pytest-socket` (if installed) with
|
||||
`--disable-socket` as an extra safety net.
|
||||
`specify self check` or `_fetch_latest_release_tag()` MUST mock
|
||||
`urllib.request.urlopen` so no real outbound call ever reaches
|
||||
api.github.com. The `self upgrade` stub tests do not need that patch because
|
||||
the stub is contractually network-free. Run this module under `pytest-socket`
|
||||
(if installed) with `--disable-socket` as an extra safety net.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import importlib.metadata
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -24,7 +24,6 @@ from specify_cli._version import (
|
||||
_normalize_tag,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
from tests.http_helpers import mock_urlopen_response
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -36,6 +35,16 @@ _RATE_LIMITED_REASON = (
|
||||
)
|
||||
|
||||
|
||||
def _mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
|
||||
|
||||
def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
|
||||
return urllib.error.HTTPError(
|
||||
url="https://api.github.com/repos/github/spec-kit/releases/latest",
|
||||
@@ -46,6 +55,39 @@ def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
|
||||
)
|
||||
|
||||
|
||||
class TestSelfUpgradeStub:
|
||||
"""Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016)."""
|
||||
|
||||
def test_prints_exactly_three_lines_and_exits_zero(self):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
lines = strip_ansi(result.output).strip().splitlines()
|
||||
assert lines == [
|
||||
"specify self upgrade is not implemented yet.",
|
||||
"Run 'specify self check' to see whether a newer release is available.",
|
||||
"Actual self-upgrade is planned as follow-up work.",
|
||||
]
|
||||
|
||||
def test_stub_makes_no_network_call(self):
|
||||
# The stub must not hit the network via either urllib path:
|
||||
# unauthenticated requests use urlopen() directly; authenticated ones
|
||||
# go through build_opener(...).open(). Both are patched so that any
|
||||
# accidental network call raises immediately.
|
||||
network_error = AssertionError("stub must not hit the network")
|
||||
with (
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=network_error,
|
||||
),
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
side_effect=network_error,
|
||||
),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestIsNewer:
|
||||
def test_latest_strictly_greater_returns_true(self):
|
||||
assert _is_newer("0.8.0", "0.7.4") is True
|
||||
@@ -109,7 +151,7 @@ class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
@@ -122,7 +164,7 @@ class TestUserStory1:
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
@@ -134,7 +176,7 @@ class TestUserStory1:
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
@@ -145,46 +187,26 @@ class TestUserStory1:
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Current version could not be determined" in output
|
||||
assert "Latest release: v0.7.4" in output
|
||||
assert "0.7.4" in output
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
assert "specify self upgrade" in output
|
||||
assert "pipx install --force git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
|
||||
def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.9.0;echo unsafe"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Latest release: vX.Y.Z" in output
|
||||
assert "Could not validate latest release tag from GitHub." in output
|
||||
assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output
|
||||
assert "v0.9.0;echo unsafe" not in output
|
||||
|
||||
def test_unparseable_tag_reports_validation_failure_without_raw_tag(self):
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Update available" not in output
|
||||
assert "Up to date" not in output
|
||||
assert "Could not validate latest release tag from GitHub." in output
|
||||
assert "Latest release: vX.Y.Z" in output
|
||||
assert "Up to date" in output
|
||||
assert "0.7.4" in output
|
||||
assert "not-a-version" not in output
|
||||
assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output
|
||||
|
||||
|
||||
class TestFailureCategorization:
|
||||
@@ -284,25 +306,13 @@ class TestUserStory2:
|
||||
def _capture_request_via_urlopen():
|
||||
captured = {}
|
||||
|
||||
def _side_effect(req, *args, **kwargs):
|
||||
def _side_effect(req, timeout=None):
|
||||
captured["request"] = req
|
||||
return mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
return _mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
|
||||
return captured, _side_effect
|
||||
|
||||
|
||||
def _capture_request_via_auth_opener():
|
||||
captured = {}
|
||||
|
||||
def _side_effect(req, *args, **kwargs):
|
||||
captured["request"] = req
|
||||
return mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = _side_effect
|
||||
return captured, opener
|
||||
|
||||
|
||||
def _inject_github_config(monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
@@ -313,11 +323,10 @@ class TestUserStory3:
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, opener = _capture_request_via_auth_opener()
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
||||
@@ -326,11 +335,10 @@ class TestUserStory3:
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, opener = _capture_request_via_auth_opener()
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
@@ -368,11 +376,10 @@ class TestUserStory3:
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, opener = _capture_request_via_auth_opener()
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Regression guard: utility and asset symbols importable from specify_cli."""
|
||||
from specify_cli import (
|
||||
check_tool, is_git_repo, merge_json_files,
|
||||
run_command, check_tool, is_git_repo, init_git_repo,
|
||||
handle_vscode_settings, merge_json_files,
|
||||
get_speckit_version,
|
||||
CLAUDE_LOCAL_PATH, CLAUDE_NPM_LOCAL_PATH,
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ def test_version_symbols_available_from_star_import():
|
||||
|
||||
def test_version_module_symbols_directly_importable():
|
||||
from specify_cli._version import (
|
||||
GITHUB_API_LATEST,
|
||||
_fetch_latest_release_tag,
|
||||
_get_installed_version,
|
||||
_is_newer,
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
"""Tests for running workflow YAML files without a project."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
class TestWorkflowRunWithoutProject:
|
||||
"""Tests that specify workflow run works with YAML files without .specify/ dir."""
|
||||
|
||||
def test_workflow_run_yaml_without_project(self, tmp_path):
|
||||
"""Running a .yml file should work without a .specify/ directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Create a minimal workflow YAML with a shell step
|
||||
workflow_file = tmp_path / "test-workflow.yml"
|
||||
workflow_content = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "standalone-test",
|
||||
"name": "Standalone Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A workflow that runs without a project",
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "create-marker",
|
||||
"type": "shell",
|
||||
"run": "echo done > marker.txt",
|
||||
},
|
||||
],
|
||||
}
|
||||
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", str(workflow_file),
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"workflow run failed: {result.output}"
|
||||
assert "completed" in result.output
|
||||
assert (tmp_path / "marker.txt").exists()
|
||||
assert (tmp_path / ".specify" / "workflows" / "runs").is_dir()
|
||||
|
||||
def test_workflow_run_yaml_with_tilde_and_uppercase_suffix(self, tmp_path, monkeypatch):
|
||||
"""Running ~/file.YML should work without a .specify/ directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
home_dir = tmp_path / "home"
|
||||
home_dir.mkdir()
|
||||
monkeypatch.setenv("HOME", str(home_dir))
|
||||
monkeypatch.setenv("USERPROFILE", str(home_dir))
|
||||
|
||||
workflow_file = home_dir / "test-workflow.YML"
|
||||
workflow_content = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "standalone-test-uppercase",
|
||||
"name": "Standalone Test Uppercase",
|
||||
"version": "1.0.0",
|
||||
"description": "A workflow that runs from ~/ with an uppercase suffix",
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "create-marker",
|
||||
"type": "shell",
|
||||
"run": "echo done > marker.txt",
|
||||
},
|
||||
],
|
||||
}
|
||||
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", "~/test-workflow.YML",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"workflow run failed: {result.output}"
|
||||
assert "Status: completed" in result.output
|
||||
assert (tmp_path / "marker.txt").exists()
|
||||
|
||||
def test_workflow_run_id_still_requires_project(self, tmp_path):
|
||||
"""Running a workflow by ID should still require a .specify/ directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", "some-workflow-id",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code != 0
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_workflow_run_missing_yaml_file(self, tmp_path):
|
||||
"""Running a non-existent .yml file should still require a project."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", "nonexistent.yml",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
# non-existent .yml files fall through to project check or file-not-found
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_workflow_run_failing_yaml_without_project(self, tmp_path):
|
||||
"""A failing workflow YAML should report failure status."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
workflow_file = tmp_path / "fail-workflow.yml"
|
||||
workflow_content = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "fail-test",
|
||||
"name": "Fail Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A workflow that fails",
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "fail-step",
|
||||
"type": "shell",
|
||||
"run": "exit 1",
|
||||
},
|
||||
],
|
||||
}
|
||||
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", str(workflow_file),
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"workflow run failed unexpectedly: {result.output}"
|
||||
assert "Status: failed" in result.output
|
||||
|
||||
def test_workflow_run_yaml_rejects_symlinked_specify_dir(self, tmp_path):
|
||||
"""Running local YAML should fail when .specify is a symlink."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
workflow_file = tmp_path / "test-workflow.yml"
|
||||
workflow_content = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "symlink-test",
|
||||
"name": "Symlink Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A workflow for symlink guard testing",
|
||||
},
|
||||
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
||||
}
|
||||
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
||||
|
||||
target_dir = tmp_path / "real-specify-dir"
|
||||
target_dir.mkdir()
|
||||
try:
|
||||
(tmp_path / ".specify").symlink_to(target_dir, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", str(workflow_file),
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "Refusing to use symlinked .specify path in current directory" in result.output
|
||||
|
||||
def test_workflow_run_yaml_rejects_non_directory_specify_path(self, tmp_path):
|
||||
"""Running local YAML should fail when .specify is not a directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
workflow_file = tmp_path / "test-workflow.yml"
|
||||
workflow_content = {
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "nondir-test",
|
||||
"name": "Non-directory Test",
|
||||
"version": "1.0.0",
|
||||
"description": "A workflow for non-directory guard testing",
|
||||
},
|
||||
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
||||
}
|
||||
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
|
||||
(tmp_path / ".specify").write_text("not a directory", encoding="utf-8")
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "run", str(workflow_file),
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert ".specify path exists but is not a directory" in result.output
|
||||
@@ -601,18 +601,15 @@ class TestCommandStep:
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
|
||||
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
|
||||
patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = step.execute(config, ctx)
|
||||
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert result.output["dispatched"] is True
|
||||
assert result.output["exit_code"] == 0
|
||||
# Verify the CLI was called with the resolved path (via shutil.which,
|
||||
# which honors PATHEXT for ``.cmd``/``.bat`` shims on Windows), then
|
||||
# ``-p`` and the skill invocation.
|
||||
# Verify the CLI was called with -p and the skill invocation
|
||||
call_args = mock_run.call_args
|
||||
assert call_args[0][0][0] == "/usr/local/bin/claude"
|
||||
assert call_args[0][0][0] == "claude"
|
||||
assert call_args[0][0][1] == "-p"
|
||||
# Claude is a SkillsIntegration so uses /speckit-specify
|
||||
assert "/speckit-specify login" in call_args[0][0][2]
|
||||
@@ -641,7 +638,6 @@ class TestCommandStep:
|
||||
mock_result.stderr = "API error"
|
||||
|
||||
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
|
||||
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
|
||||
patch("subprocess.run", return_value=mock_result):
|
||||
result = step.execute(config, ctx)
|
||||
|
||||
@@ -2720,112 +2716,6 @@ class TestRunState:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
RunState.load("nonexistent", project_dir)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malicious_run_id",
|
||||
[
|
||||
# Parent-directory traversal — the classic path-escape vector.
|
||||
"../escape",
|
||||
"..",
|
||||
"../../etc/passwd",
|
||||
# Embedded path separators — both POSIX and Windows.
|
||||
"foo/bar",
|
||||
"foo\\bar",
|
||||
# Leading non-alphanumeric characters that the existing
|
||||
# pattern's anchor blocks (would be mistaken for CLI flags
|
||||
# or hidden files in shell completions / error messages).
|
||||
".hidden",
|
||||
"-flag",
|
||||
# NUL byte — some filesystems treat the prefix as a valid
|
||||
# path and silently truncate at the NUL.
|
||||
"foo\x00bar",
|
||||
# Empty string — degenerate case, matches no file but the
|
||||
# validator should reject it before any I/O.
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_load_rejects_path_traversal(self, project_dir, malicious_run_id):
|
||||
"""``RunState.load`` validates ``run_id`` before touching the
|
||||
filesystem.
|
||||
|
||||
Without this guard, a value like ``../escape`` passed via
|
||||
``specify workflow resume`` would interpolate path-traversal
|
||||
segments into the lookup path. ``state_path.exists()`` would
|
||||
probe arbitrary paths the process can read (a file-existence
|
||||
oracle) and ``json.load`` would happily parse attacker-planted
|
||||
JSON from outside ``.specify/workflows/runs/``. The check must
|
||||
fire *before* the path is built — ``__init__``'s identical
|
||||
regex on ``state_data["run_id"]`` fires too late.
|
||||
"""
|
||||
from specify_cli.workflows.engine import RunState
|
||||
|
||||
# Plant a state.json *outside* the legitimate ``runs/`` directory
|
||||
# at the location ``../escape`` would traverse to, so a missing
|
||||
# guard would surface as a successful load rather than a
|
||||
# ``FileNotFoundError`` (which would be ambiguous with the
|
||||
# not-found case).
|
||||
runs_dir = project_dir / ".specify" / "workflows" / "runs"
|
||||
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||
attacker_dir = project_dir / ".specify" / "workflows" / "escape"
|
||||
attacker_dir.mkdir(exist_ok=True)
|
||||
(attacker_dir / "state.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"run_id": "pwned",
|
||||
"workflow_id": "attacker-owned",
|
||||
"status": "created",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid run_id"):
|
||||
RunState.load(malicious_run_id, project_dir)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_run_id",
|
||||
[
|
||||
# One vector per category from ``test_load_rejects_path_traversal``
|
||||
# — enough to prove both entry points agree without re-running
|
||||
# the full attack matrix here.
|
||||
"../escape", # parent-directory traversal
|
||||
"foo/bar", # embedded path separator
|
||||
".hidden", # leading non-alphanumeric
|
||||
"", # empty / degenerate
|
||||
],
|
||||
)
|
||||
def test_init_and_load_share_validation(self, project_dir, bad_run_id):
|
||||
"""``__init__`` *and* ``load`` reject the same malformed IDs.
|
||||
|
||||
The two entry points must stay in sync — drift would let an ID
|
||||
slip in via one path that the other would reject, producing
|
||||
confusing crashes mid-workflow. The previous version of this
|
||||
test only exercised ``__init__`` and ``_validate_run_id`` (the
|
||||
shared helper), so a regression in ``load`` — e.g. someone
|
||||
deleting the ``cls._validate_run_id(run_id)`` call there — could
|
||||
slip through despite ``__init__`` and the helper staying
|
||||
aligned. We now hit ``load`` directly with the same vector so
|
||||
any drift between the two call sites is caught by this test.
|
||||
"""
|
||||
from specify_cli.workflows.engine import RunState
|
||||
|
||||
# ``__init__`` rejects up front.
|
||||
with pytest.raises(ValueError, match="Invalid run_id"):
|
||||
RunState(run_id=bad_run_id)
|
||||
|
||||
# The shared helper rejects the value too (sanity check that the
|
||||
# ``__init__`` rejection came from the validator, not some
|
||||
# unrelated constructor failure).
|
||||
with pytest.raises(ValueError, match="Invalid run_id"):
|
||||
RunState._validate_run_id(bad_run_id)
|
||||
|
||||
# And ``load`` rejects it *before* touching the filesystem. This
|
||||
# is the assertion the previous version was missing: without it,
|
||||
# a regression in ``load`` (e.g. forgetting to call the
|
||||
# validator before building the path) would not be caught even
|
||||
# though ``__init__`` and the helper still agreed.
|
||||
with pytest.raises(ValueError, match="Invalid run_id"):
|
||||
RunState.load(bad_run_id, project_dir)
|
||||
|
||||
def test_append_log(self, project_dir):
|
||||
from specify_cli.workflows.engine import RunState
|
||||
|
||||
@@ -3136,270 +3026,3 @@ steps:
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
assert "do-plan" in state.step_results
|
||||
assert "do-specify" not in state.step_results
|
||||
|
||||
|
||||
class TestWorkflowJsonOutput:
|
||||
"""Test the --json machine-readable output for run/resume/status."""
|
||||
|
||||
_WF = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "json-wf"
|
||||
name: "JSON WF"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: ask
|
||||
type: gate
|
||||
message: "Review"
|
||||
options: [approve, reject]
|
||||
- id: after
|
||||
type: shell
|
||||
run: "echo done"
|
||||
"""
|
||||
|
||||
_WF_DONE = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "json-done"
|
||||
name: "JSON Done"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: only
|
||||
type: shell
|
||||
run: "echo done"
|
||||
"""
|
||||
|
||||
def _write_wf(self, project_dir, text, name):
|
||||
path = project_dir / f"{name}.yml"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def _invoke(self, project_dir, args):
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
return runner.invoke(app, args, catch_exceptions=False)
|
||||
|
||||
def test_run_json_completed(self, project_dir):
|
||||
wf = self._write_wf(project_dir, self._WF_DONE, "done")
|
||||
result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["workflow_id"] == "json-done"
|
||||
assert payload["status"] == "completed"
|
||||
assert "run_id" in payload
|
||||
|
||||
def test_run_json_paused(self, project_dir):
|
||||
wf = self._write_wf(project_dir, self._WF, "gated")
|
||||
result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["status"] == "paused"
|
||||
assert payload["current_step_id"] == "ask"
|
||||
assert payload["current_step_index"] == 0
|
||||
|
||||
def test_run_json_output_has_no_markup_or_ansi(self, project_dir):
|
||||
wf = self._write_wf(project_dir, self._WF_DONE, "clean")
|
||||
out = self._invoke(
|
||||
project_dir, ["workflow", "run", str(wf), "--json"]
|
||||
).stdout
|
||||
# Machine output must be exactly the JSON object: no Rich markup
|
||||
# tags and no ANSI escape sequences leaking in.
|
||||
assert "\x1b[" not in out
|
||||
assert "[/" not in out
|
||||
assert out.strip() == json.dumps(json.loads(out), indent=2)
|
||||
|
||||
def test_run_default_output_is_human_not_json(self, project_dir):
|
||||
wf = self._write_wf(project_dir, self._WF_DONE, "done2")
|
||||
result = self._invoke(project_dir, ["workflow", "run", str(wf)])
|
||||
assert result.exit_code == 0
|
||||
assert "Running workflow" in result.stdout
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
json.loads(result.stdout)
|
||||
|
||||
def test_status_json_single_and_list(self, project_dir):
|
||||
wf = self._write_wf(project_dir, self._WF, "gated2")
|
||||
run = json.loads(
|
||||
self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout
|
||||
)
|
||||
rid = run["run_id"]
|
||||
|
||||
single = json.loads(
|
||||
self._invoke(project_dir, ["workflow", "status", rid, "--json"]).stdout
|
||||
)
|
||||
assert single["run_id"] == rid
|
||||
assert single["status"] == "paused"
|
||||
assert single["steps"]["ask"] == "paused"
|
||||
# status --json carries the same step-position fields as run/resume
|
||||
# so automation never has to branch on which command produced it.
|
||||
assert single["current_step_id"] == run["current_step_id"]
|
||||
assert single["current_step_index"] == run["current_step_index"]
|
||||
|
||||
listing = json.loads(
|
||||
self._invoke(project_dir, ["workflow", "status", "--json"]).stdout
|
||||
)
|
||||
assert any(r["run_id"] == rid for r in listing["runs"])
|
||||
|
||||
def test_resume_json(self, project_dir):
|
||||
wf = self._write_wf(project_dir, self._WF, "gated3")
|
||||
rid = json.loads(
|
||||
self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout
|
||||
)["run_id"]
|
||||
# Non-interactive resume re-runs the gate, which pauses again.
|
||||
resumed = json.loads(
|
||||
self._invoke(project_dir, ["workflow", "resume", rid, "--json"]).stdout
|
||||
)
|
||||
assert resumed["run_id"] == rid
|
||||
assert resumed["status"] == "paused"
|
||||
|
||||
def test_json_redirect_keeps_stdout_clean(self, capfd):
|
||||
# While a workflow runs under --json, steps can still write to stdout:
|
||||
# the gate step prints its prompt and the prompt step runs a
|
||||
# subprocess that inherits the stdout fd. Both must be redirected to
|
||||
# stderr so the JSON object on stdout stays parseable. capfd captures
|
||||
# at the file-descriptor level, so it sees the subprocess output too.
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
from specify_cli import _stdout_to_stderr_when
|
||||
|
||||
print("STDOUT_BEFORE")
|
||||
with _stdout_to_stderr_when(True):
|
||||
print("PY_LEAK") # Python-level write (gate-style)
|
||||
subprocess.run( # inherited-fd write (prompt-style)
|
||||
[_sys.executable, "-c", "print('SUBPROC_LEAK')"],
|
||||
check=True,
|
||||
)
|
||||
print("STDOUT_AFTER")
|
||||
|
||||
out, err = capfd.readouterr()
|
||||
# stdout keeps only what was written outside the guarded block.
|
||||
assert "STDOUT_BEFORE" in out and "STDOUT_AFTER" in out
|
||||
assert "PY_LEAK" not in out and "SUBPROC_LEAK" not in out
|
||||
# The step output is preserved on stderr, not discarded.
|
||||
assert "PY_LEAK" in err and "SUBPROC_LEAK" in err
|
||||
|
||||
def test_json_redirect_inactive_is_noop(self, capfd):
|
||||
from specify_cli import _stdout_to_stderr_when
|
||||
|
||||
with _stdout_to_stderr_when(False):
|
||||
print("VISIBLE_ON_STDOUT")
|
||||
out, _ = capfd.readouterr()
|
||||
assert "VISIBLE_ON_STDOUT" in out
|
||||
|
||||
|
||||
class TestResumeWithInputs:
|
||||
"""Test that `workflow resume` can accept updated workflow inputs."""
|
||||
|
||||
_WF_CMD = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "resume-cmd-wf"
|
||||
name: "Resume Cmd WF"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
cmd:
|
||||
type: string
|
||||
default: "exit 1"
|
||||
steps:
|
||||
- id: s
|
||||
type: shell
|
||||
run: "{{ inputs.cmd }}"
|
||||
"""
|
||||
|
||||
_WF_NUM = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "resume-num-wf"
|
||||
name: "Resume Num WF"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
count:
|
||||
type: number
|
||||
default: 1
|
||||
steps:
|
||||
- id: gate
|
||||
type: gate
|
||||
message: "Review"
|
||||
options: [approve, reject]
|
||||
"""
|
||||
|
||||
def _engine(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
return WorkflowEngine(project_dir)
|
||||
|
||||
def test_resume_with_input_reruns_step_with_new_value(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
definition = WorkflowDefinition.from_string(self._WF_CMD)
|
||||
engine = self._engine(project_dir)
|
||||
|
||||
state = engine.execute(definition)
|
||||
assert state.status == RunStatus.FAILED # "exit 1" fails
|
||||
|
||||
resumed = engine.resume(state.run_id, {"cmd": "exit 0"})
|
||||
assert resumed.status == RunStatus.COMPLETED
|
||||
assert resumed.inputs["cmd"] == "exit 0"
|
||||
|
||||
def test_resume_without_input_preserves_inputs(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
definition = WorkflowDefinition.from_string(self._WF_CMD)
|
||||
engine = self._engine(project_dir)
|
||||
|
||||
state = engine.execute(definition)
|
||||
assert state.status == RunStatus.FAILED
|
||||
|
||||
resumed = engine.resume(state.run_id)
|
||||
assert resumed.status == RunStatus.FAILED # still "exit 1"
|
||||
assert resumed.inputs["cmd"] == "exit 1"
|
||||
|
||||
def test_resume_merges_and_coerces_typed_input(self, project_dir):
|
||||
import json as _json
|
||||
from specify_cli.workflows.engine import WorkflowDefinition
|
||||
from specify_cli.workflows.base import RunStatus
|
||||
|
||||
definition = WorkflowDefinition.from_string(self._WF_NUM)
|
||||
engine = self._engine(project_dir)
|
||||
|
||||
state = engine.execute(definition)
|
||||
assert state.status == RunStatus.PAUSED
|
||||
|
||||
resumed = engine.resume(state.run_id, {"count": "5"})
|
||||
assert resumed.inputs["count"] == 5 # coerced string -> number
|
||||
|
||||
inputs_file = (
|
||||
project_dir / ".specify" / "workflows" / "runs" / state.run_id / "inputs.json"
|
||||
)
|
||||
assert _json.loads(inputs_file.read_text())["inputs"]["count"] == 5
|
||||
|
||||
def test_resume_invalid_typed_input_raises(self, project_dir):
|
||||
from specify_cli.workflows.engine import WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string(self._WF_NUM)
|
||||
engine = self._engine(project_dir)
|
||||
|
||||
state = engine.execute(definition)
|
||||
with pytest.raises(ValueError):
|
||||
engine.resume(state.run_id, {"count": "not-a-number"})
|
||||
|
||||
def test_cli_resume_input_invalid_format_errors(self, project_dir):
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.engine import WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string(self._WF_NUM)
|
||||
state = self._engine(project_dir).execute(definition)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "resume", state.run_id, "--input", "bogus"]
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid input format" in result.stdout
|
||||
|
||||
Reference in New Issue
Block a user