mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 15:06:10 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4e650d48 | ||
|
|
9dfa53d2e9 | ||
|
|
cedbf484d7 | ||
|
|
75df458c37 | ||
|
|
071f784dfa | ||
|
|
1ee2b626a8 | ||
|
|
811a3aa447 | ||
|
|
de18d21b1c | ||
|
|
75aee19c6e | ||
|
|
ae23a84677 | ||
|
|
3e69233adb |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -50,3 +50,12 @@ docs/dev
|
|||||||
.specify/extensions/.cache/
|
.specify/extensions/.cache/
|
||||||
.specify/extensions/.backup/
|
.specify/extensions/.backup/
|
||||||
.specify/extensions/*/local-config.yml
|
.specify/extensions/*/local-config.yml
|
||||||
|
|
||||||
|
# The following directories/file are intentionally ignored so that they are not accidentally
|
||||||
|
# committed to the repository. They contain the scaffolding `specify init --integration copilot`
|
||||||
|
# does and they are meant for dogfooding Spec Kit during its own feature development.
|
||||||
|
.github/agents/
|
||||||
|
.github/prompts/
|
||||||
|
.github/copilot-instructions.md
|
||||||
|
.specify/
|
||||||
|
specs/
|
||||||
|
|||||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -2,6 +2,37 @@
|
|||||||
|
|
||||||
<!-- insert new changelog below this comment -->
|
<!-- insert new changelog below this comment -->
|
||||||
|
|
||||||
|
## [0.11.1] - 2026-06-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- chore: ignore Copilot dogfooding scaffolding in .gitignore (#3019)
|
||||||
|
- docs: clarify Taskify specify command (#3016)
|
||||||
|
- docs: document evolving specs in existing projects (#2902)
|
||||||
|
- feat(workflows): opt-in output_format: json exposes parsed shell stdout as output.data (#2963)
|
||||||
|
- fix: non-zero exit code when a workflow run ends failed or aborted (#2959)
|
||||||
|
- fix(skills): preserve non-ASCII characters in skill frontmatter (#2917)
|
||||||
|
- fix: prevent extension self-install from deleting source dir (#2990) (#2991)
|
||||||
|
- fix: disable Rich Live transient mode on Windows to prevent PS 5.1 hang (#2938)
|
||||||
|
- Update a11y-governance preset to v0.4.0 (#2981)
|
||||||
|
- chore: release 0.11.0, begin 0.11.1.dev0 development (#3012)
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-06-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Add workflow step catalog — community-installable step types (#2394)
|
||||||
|
- feat(dev): add integration scaffolder (#2685)
|
||||||
|
- Add Command Density preset to community catalog (#3006)
|
||||||
|
- fix(tests): don't run PowerShell tests via WSL-interop powershell.exe (#2971)
|
||||||
|
- Add Zed integration (#2780)
|
||||||
|
- Update architecture-governance preset to v0.5.0 (#2929)
|
||||||
|
- Update Superpowers Implementation Bridge extension to v1.1.0 (#3011)
|
||||||
|
- Update isaqb-architecture-governance preset to v0.2.0 (#2984)
|
||||||
|
- Update security-governance preset to v0.6.0 (#2932)
|
||||||
|
- chore: update CITATION.cff to v0.10.2 (2026-06-11) (#2966)
|
||||||
|
- chore: release 0.10.4, begin 0.10.5.dev0 development (#3010)
|
||||||
|
|
||||||
## [0.10.4] - 2026-06-16
|
## [0.10.4] - 2026-06-16
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -254,6 +254,12 @@ Spec-Driven Development is a structured process that emphasizes:
|
|||||||
| **Creative Exploration** | Parallel implementations | <ul><li>Explore diverse solutions</li><li>Support multiple technology stacks & architectures</li><li>Experiment with UX patterns</li></ul> |
|
| **Creative Exploration** | Parallel implementations | <ul><li>Explore diverse solutions</li><li>Support multiple technology stacks & architectures</li><li>Experiment with UX patterns</li></ul> |
|
||||||
| **Iterative Enhancement** ("Brownfield") | Brownfield modernization | <ul><li>Add features iteratively</li><li>Modernize legacy systems</li><li>Adapt processes</li></ul> |
|
| **Iterative Enhancement** ("Brownfield") | Brownfield modernization | <ul><li>Add features iteratively</li><li>Modernize legacy systems</li><li>Adapt processes</li></ul> |
|
||||||
|
|
||||||
|
For existing projects, keep Spec Kit tooling updates separate from feature
|
||||||
|
artifact evolution: refresh managed project files when upgrading, and update
|
||||||
|
`specs/` artifacts when intended behavior changes. The
|
||||||
|
[Evolving Specs guide](./docs/guides/evolving-specs.md) describes the
|
||||||
|
recommended brownfield loop.
|
||||||
|
|
||||||
## 🎯 Experimental Goals
|
## 🎯 Experimental Goals
|
||||||
|
|
||||||
Our research and experimentation focus on:
|
Our research and experimentation focus on:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
|||||||
|
|
||||||
| Preset | Purpose | Provides | Requires | URL |
|
| Preset | Purpose | Provides | Requires | URL |
|
||||||
|--------|---------|----------|----------|-----|
|
|--------|---------|----------|----------|-----|
|
||||||
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, inclusive-content guidance, and didactic inline-code-comment review | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||||
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||||
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ Spec-Driven Development is a structured process that emphasizes:
|
|||||||
|
|
||||||
Spec Kit does not prescribe how teams preserve or mutate `spec.md`, `plan.md`,
|
Spec Kit does not prescribe how teams preserve or mutate `spec.md`, `plan.md`,
|
||||||
and `tasks.md` after requirements change. See
|
and `tasks.md` after requirements change. See
|
||||||
[Spec Persistence Models](spec-persistence.md) for three common ways to manage
|
[Spec Persistence Models](spec-persistence.md) for the concepts and
|
||||||
those artifacts over time.
|
[Evolving Specs in Existing Projects](../guides/evolving-specs.md) for the
|
||||||
|
existing-project evolution workflows.
|
||||||
|
|
||||||
## Development Phases
|
## Development Phases
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"toc.yml",
|
"toc.yml",
|
||||||
"community/*.md",
|
"community/*.md",
|
||||||
"concepts/*.md",
|
"concepts/*.md",
|
||||||
|
"guides/*.md",
|
||||||
"reference/*.md",
|
"reference/*.md",
|
||||||
"install/*.md"
|
"install/*.md"
|
||||||
]
|
]
|
||||||
@@ -78,4 +79,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
90
docs/guides/evolving-specs.md
Normal file
90
docs/guides/evolving-specs.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Evolving Specs in Existing Projects
|
||||||
|
|
||||||
|
Existing projects need two separate maintenance loops:
|
||||||
|
|
||||||
|
- **Spec Kit project-file updates** refresh managed commands, scripts,
|
||||||
|
templates, and shared memory files.
|
||||||
|
- **Feature artifact evolution** keeps repository-specific `specs/` artifacts
|
||||||
|
aligned with the code and product behavior you intend to ship.
|
||||||
|
|
||||||
|
Use the [upgrade workflow](../upgrade.md) when you need newer Spec Kit project
|
||||||
|
files. Use one of the artifact persistence models below when requirements or
|
||||||
|
implementation insights change an existing project.
|
||||||
|
|
||||||
|
For the conceptual model definitions, see
|
||||||
|
[Spec Persistence Models](../concepts/spec-persistence.md).
|
||||||
|
|
||||||
|
## Flow-Forward Spec
|
||||||
|
|
||||||
|
Use flow-forward when each feature directory should remain a historical record.
|
||||||
|
|
||||||
|
When you add another feature or make a substantial follow-up change, create a
|
||||||
|
new feature spec through your installed `/speckit.specify` command and continue
|
||||||
|
through the standard flow:
|
||||||
|
|
||||||
|
1. Run `/speckit.specify` to create a new feature directory under `specs/`.
|
||||||
|
2. Run `/speckit.plan` to define the implementation approach.
|
||||||
|
3. Run `/speckit.tasks` to derive the work breakdown.
|
||||||
|
4. Run `/speckit.implement` and review the resulting code and artifact diffs.
|
||||||
|
|
||||||
|
The previous feature directory remains intact for audit, comparison, or
|
||||||
|
explaining how the project reached its current state. Use clear feature names or
|
||||||
|
cross-links when a new directory supersedes or extends earlier work.
|
||||||
|
|
||||||
|
## Living Spec
|
||||||
|
|
||||||
|
Use living spec when `spec.md` is the contract and `plan.md` and `tasks.md` are
|
||||||
|
derived from it.
|
||||||
|
|
||||||
|
When intended behavior changes, revise the existing `spec.md` first. Then
|
||||||
|
regenerate or manually revise downstream artifacts so they match the updated
|
||||||
|
spec:
|
||||||
|
|
||||||
|
1. Start from a clean working tree or a dedicated branch so every generated
|
||||||
|
change is reviewable.
|
||||||
|
2. Update `spec.md` with `/speckit.clarify` or an explicit edit.
|
||||||
|
3. Rerun `/speckit.plan` or revise `plan.md` so the technical approach matches
|
||||||
|
the revised spec.
|
||||||
|
4. Rerun `/speckit.tasks` or revise `tasks.md` so implementation work matches
|
||||||
|
the revised plan.
|
||||||
|
5. Run `/speckit.analyze` before implementation resumes to catch gaps between
|
||||||
|
the spec, plan, and tasks.
|
||||||
|
6. Run `/speckit.implement`, then review the code and artifact diffs together.
|
||||||
|
|
||||||
|
Preserve important implementation rationale before replacing derived artifacts.
|
||||||
|
If a plan or task list contains decisions that still matter, carry them forward
|
||||||
|
explicitly.
|
||||||
|
|
||||||
|
## Flow-Back Spec
|
||||||
|
|
||||||
|
Use flow-back when implementation discoveries are allowed to reshape the
|
||||||
|
artifact set.
|
||||||
|
|
||||||
|
In this model, the first useful edit can happen wherever the insight lands:
|
||||||
|
`spec.md`, `plan.md`, `tasks.md`, or the implementation. After the change, bring
|
||||||
|
the artifact set back into alignment:
|
||||||
|
|
||||||
|
1. Capture the discovery in the artifact closest to the work.
|
||||||
|
2. Decide whether it changes intended behavior, implementation strategy, task
|
||||||
|
breakdown, or only code.
|
||||||
|
3. Update any other artifacts that now disagree with the accepted direction.
|
||||||
|
4. Run `/speckit.analyze` to check for gaps across `spec.md`, `plan.md`, and
|
||||||
|
`tasks.md`.
|
||||||
|
5. Continue implementation only after the artifact set describes the behavior
|
||||||
|
and approach you want future contributors to trust.
|
||||||
|
|
||||||
|
Flow-back is flexible, but it requires discipline. Do not leave a lower-level
|
||||||
|
change in `tasks.md` or code if `spec.md` still says something different and the
|
||||||
|
spec is meant to remain trustworthy.
|
||||||
|
|
||||||
|
## Before Updating Spec Kit Project Files
|
||||||
|
|
||||||
|
Before refreshing Spec Kit project files with the terminal command
|
||||||
|
`specify init --here --force --integration <your-agent>`, protect any
|
||||||
|
project-specific material that lives outside `specs/`, especially
|
||||||
|
`.specify/memory/constitution.md` and customized files under
|
||||||
|
`.specify/templates/` or `.specify/scripts/`. Use `<your-agent>` for the AI
|
||||||
|
coding agent integration used by the target project.
|
||||||
|
|
||||||
|
Your `specs/` directory is not part of the template package, but shared project
|
||||||
|
files can be overwritten by a forced refresh.
|
||||||
@@ -127,7 +127,7 @@ Initialize the project's constitution to set ground rules:
|
|||||||
### Step 2: Define Requirements with `/speckit.specify`
|
### Step 2: Define Requirements with `/speckit.specify`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Develop Taskify, a team productivity platform. It should allow users to create projects, add team members,
|
/speckit.specify Develop Taskify, a team productivity platform. It should allow users to create projects, add team members,
|
||||||
assign tasks, comment and move tasks between boards in Kanban style. In this initial phase for this feature,
|
assign tasks, comment and move tasks between boards in Kanban style. In this initial phase for this feature,
|
||||||
let's call it "Create Taskify," let's have multiple users but the users will be declared ahead of time, predefined.
|
let's call it "Create Taskify," let's have multiple users but the users will be declared ahead of time, predefined.
|
||||||
I want five users in two different categories, one product manager and four engineers. Let's create three
|
I want five users in two different categories, one product manager and four engineers. Let's create three
|
||||||
|
|||||||
@@ -51,6 +51,8 @@
|
|||||||
items:
|
items:
|
||||||
- name: Local Development
|
- name: Local Development
|
||||||
href: local-development.md
|
href: local-development.md
|
||||||
|
- name: Evolving Specs
|
||||||
|
href: guides/evolving-specs.md
|
||||||
|
|
||||||
# Community
|
# Community
|
||||||
- name: Community
|
- name: Community
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
"a11y-governance": {
|
"a11y-governance": {
|
||||||
"name": "A11Y Governance",
|
"name": "A11Y Governance",
|
||||||
"id": "a11y-governance",
|
"id": "a11y-governance",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, and didactic inline-code-comment review to Spec Kit.",
|
"description": "Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence.",
|
||||||
"author": "Thorsten Hindermann",
|
"author": "Thorsten Hindermann",
|
||||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.3.0.zip",
|
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.0.zip",
|
||||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
|
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -26,10 +26,14 @@
|
|||||||
"accessibility",
|
"accessibility",
|
||||||
"bilingual",
|
"bilingual",
|
||||||
"wcag",
|
"wcag",
|
||||||
"inclusion"
|
"wcag-2-2",
|
||||||
|
"cefr-b2",
|
||||||
|
"inclusion",
|
||||||
|
"include-everyone",
|
||||||
|
"didactic-comments"
|
||||||
],
|
],
|
||||||
"created_at": "2026-04-27T00:00:00Z",
|
"created_at": "2026-04-27T00:00:00Z",
|
||||||
"updated_at": "2026-06-05T00:00:00Z"
|
"updated_at": "2026-06-14T00:00:00Z"
|
||||||
},
|
},
|
||||||
"agent-parity-governance": {
|
"agent-parity-governance": {
|
||||||
"name": "Agent Parity Governance",
|
"name": "Agent Parity Governance",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.10.5.dev0"
|
version = "0.11.1"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -2100,6 +2100,16 @@ def _workflow_run_payload(state: Any) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_outcome_exit_code(status_value: str) -> int:
|
||||||
|
"""Exit code for a finished run/resume: non-zero on terminal failure.
|
||||||
|
|
||||||
|
``failed`` and ``aborted`` map to 1 so scripts and orchestrators can
|
||||||
|
rely on the process exit code; ``completed`` and ``paused`` map to 0
|
||||||
|
(paused is a legitimate waiting state, not a failure).
|
||||||
|
"""
|
||||||
|
return 1 if status_value in ("failed", "aborted") else 0
|
||||||
|
|
||||||
|
|
||||||
def _emit_workflow_json(payload: dict[str, Any]) -> None:
|
def _emit_workflow_json(payload: dict[str, Any]) -> None:
|
||||||
"""Write a workflow payload as machine-readable JSON to stdout.
|
"""Write a workflow payload as machine-readable JSON to stdout.
|
||||||
|
|
||||||
@@ -2214,7 +2224,7 @@ def workflow_run(
|
|||||||
|
|
||||||
if json_output:
|
if json_output:
|
||||||
_emit_workflow_json(_workflow_run_payload(state))
|
_emit_workflow_json(_workflow_run_payload(state))
|
||||||
return
|
raise typer.Exit(_run_outcome_exit_code(state.status.value))
|
||||||
|
|
||||||
status_colors = {
|
status_colors = {
|
||||||
"completed": "green",
|
"completed": "green",
|
||||||
@@ -2229,6 +2239,8 @@ def workflow_run(
|
|||||||
if state.status.value == "paused":
|
if state.status.value == "paused":
|
||||||
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
|
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
|
||||||
|
|
||||||
|
raise typer.Exit(_run_outcome_exit_code(state.status.value))
|
||||||
|
|
||||||
|
|
||||||
@workflow_app.command("resume")
|
@workflow_app.command("resume")
|
||||||
def workflow_resume(
|
def workflow_resume(
|
||||||
@@ -2269,7 +2281,7 @@ def workflow_resume(
|
|||||||
|
|
||||||
if json_output:
|
if json_output:
|
||||||
_emit_workflow_json(_workflow_run_payload(state))
|
_emit_workflow_json(_workflow_run_payload(state))
|
||||||
return
|
raise typer.Exit(_run_outcome_exit_code(state.status.value))
|
||||||
|
|
||||||
status_colors = {
|
status_colors = {
|
||||||
"completed": "green",
|
"completed": "green",
|
||||||
@@ -2280,6 +2292,8 @@ def workflow_resume(
|
|||||||
color = status_colors.get(state.status.value, "white")
|
color = status_colors.get(state.status.value, "white")
|
||||||
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
||||||
|
|
||||||
|
raise typer.Exit(_run_outcome_exit_code(state.status.value))
|
||||||
|
|
||||||
|
|
||||||
@workflow_app.command("status")
|
@workflow_app.command("status")
|
||||||
def workflow_status(
|
def workflow_status(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ layer, not out of it, to avoid circular imports.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
import readchar
|
import readchar
|
||||||
@@ -192,7 +193,8 @@ def select_with_arrows(
|
|||||||
|
|
||||||
def run_selection_loop():
|
def run_selection_loop():
|
||||||
nonlocal selected_key, selected_index
|
nonlocal selected_key, selected_index
|
||||||
with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live:
|
_transient = sys.platform != "win32"
|
||||||
|
with Live(create_selection_panel(), console=console, transient=_transient, auto_refresh=False) as live:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
key = get_key()
|
key = get_key()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import shutil
|
|||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import yaml
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from ._console import console
|
from ._console import console
|
||||||
@@ -16,6 +17,16 @@ CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
|
|||||||
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
|
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
|
||||||
|
|
||||||
|
|
||||||
|
def dump_frontmatter(data: dict[str, Any]) -> str:
|
||||||
|
"""Serialize skill/command frontmatter to a YAML string.
|
||||||
|
|
||||||
|
Centralizes the dump options used for SKILL.md frontmatter: ``allow_unicode``
|
||||||
|
preserves Unicode descriptions and ``sort_keys=False`` keeps key order, so no
|
||||||
|
call site can silently drop either.
|
||||||
|
"""
|
||||||
|
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
|
||||||
|
|
||||||
|
|
||||||
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
|
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
|
||||||
"""Run a shell command and optionally capture output."""
|
"""Run a shell command and optionally capture output."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -381,8 +381,12 @@ def register(app: typer.Typer) -> None:
|
|||||||
]:
|
]:
|
||||||
tracker.add(key, label)
|
tracker.add(key, label)
|
||||||
|
|
||||||
|
# Disable transient mode on Windows: PowerShell 5.1's legacy console
|
||||||
|
# hangs when Rich tries to restore cursor state via VT escape sequences.
|
||||||
|
_transient = sys.platform != "win32"
|
||||||
|
|
||||||
with Live(
|
with Live(
|
||||||
tracker.render(), console=console, refresh_per_second=8, transient=True
|
tracker.render(), console=console, refresh_per_second=8, transient=_transient
|
||||||
) as live:
|
) as live:
|
||||||
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
||||||
try:
|
try:
|
||||||
@@ -652,7 +656,8 @@ def register(app: typer.Typer) -> None:
|
|||||||
finally:
|
finally:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
console.print(tracker.render())
|
if _transient:
|
||||||
|
console.print(tracker.render())
|
||||||
console.print("\n[bold green]Project ready.[/bold green]")
|
console.print("\n[bold green]Project ready.[/bold green]")
|
||||||
|
|
||||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|||||||
|
|
||||||
from ._init_options import is_ai_skills_enabled
|
from ._init_options import is_ai_skills_enabled
|
||||||
from ._invocation_style import is_slash_skills_agent
|
from ._invocation_style import is_slash_skills_agent
|
||||||
|
from ._utils import dump_frontmatter
|
||||||
from .catalogs import CatalogEntry as BaseCatalogEntry
|
from .catalogs import CatalogEntry as BaseCatalogEntry
|
||||||
from .catalogs import CatalogStackBase
|
from .catalogs import CatalogStackBase
|
||||||
|
|
||||||
@@ -1073,7 +1074,7 @@ class ExtensionManager:
|
|||||||
and hasattr(integration, "inject_argument_hint")
|
and hasattr(integration, "inject_argument_hint")
|
||||||
):
|
):
|
||||||
frontmatter_data["argument-hint"] = str(argument_hint)
|
frontmatter_data["argument-hint"] = str(argument_hint)
|
||||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
frontmatter_text = dump_frontmatter(frontmatter_data)
|
||||||
|
|
||||||
# Derive a human-friendly title from the command name
|
# Derive a human-friendly title from the command name
|
||||||
short_name = cmd_name
|
short_name = cmd_name
|
||||||
@@ -1337,6 +1338,22 @@ class ExtensionManager:
|
|||||||
# Reject manifests that would shadow core commands or installed extensions.
|
# Reject manifests that would shadow core commands or installed extensions.
|
||||||
self._validate_install_conflicts(manifest)
|
self._validate_install_conflicts(manifest)
|
||||||
|
|
||||||
|
# Refuse to install an extension from its own install destination — with
|
||||||
|
# --force this would delete the source before copying it (issue #2990).
|
||||||
|
dest_dir = self.extensions_dir / manifest.id
|
||||||
|
try:
|
||||||
|
same_location = source_dir.resolve(strict=False) == dest_dir.resolve(
|
||||||
|
strict=False
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError):
|
||||||
|
same_location = source_dir.absolute() == dest_dir.absolute()
|
||||||
|
if same_location:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Source path is the install destination for '{manifest.id}' "
|
||||||
|
f"({dest_dir}). Refusing to proceed to avoid deleting the "
|
||||||
|
f"extension. Install from a copy in a different location instead."
|
||||||
|
)
|
||||||
|
|
||||||
# Remove existing installation AFTER all validations pass so that a
|
# Remove existing installation AFTER all validations pass so that a
|
||||||
# validation failure doesn't leave the user with a half-uninstalled
|
# validation failure doesn't leave the user with a half-uninstalled
|
||||||
# extension (configs stranded in .backup/).
|
# extension (configs stranded in .backup/).
|
||||||
@@ -1355,8 +1372,7 @@ class ExtensionManager:
|
|||||||
backup_config_dir.unlink()
|
backup_config_dir.unlink()
|
||||||
did_remove = self.remove(manifest.id)
|
did_remove = self.remove(manifest.id)
|
||||||
|
|
||||||
# Install extension
|
# Install extension (dest_dir computed above during self-install guard)
|
||||||
dest_dir = self.extensions_dir / manifest.id
|
|
||||||
if dest_dir.exists():
|
if dest_dir.exists():
|
||||||
shutil.rmtree(dest_dir)
|
shutil.rmtree(dest_dir)
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from ..base import SkillsIntegration
|
from ..base import SkillsIntegration
|
||||||
from ..manifest import IntegrationManifest
|
from ..manifest import IntegrationManifest
|
||||||
|
from ..._utils import dump_frontmatter
|
||||||
|
|
||||||
# Mapping of command template stem → argument-hint text shown inline
|
# Mapping of command template stem → argument-hint text shown inline
|
||||||
# when a user invokes the slash command in Claude Code.
|
# when a user invokes the slash command in Claude Code.
|
||||||
@@ -103,7 +102,7 @@ class ClaudeIntegration(SkillsIntegration):
|
|||||||
skill_frontmatter = self._build_skill_fm(
|
skill_frontmatter = self._build_skill_fm(
|
||||||
skill_name, description, f"templates/commands/{template_name}.md"
|
skill_name, description, f"templates/commands/{template_name}.md"
|
||||||
)
|
)
|
||||||
frontmatter_text = yaml.safe_dump(skill_frontmatter, sort_keys=False).strip()
|
frontmatter_text = dump_frontmatter(skill_frontmatter)
|
||||||
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
|
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
|
||||||
|
|
||||||
def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
|
def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
|||||||
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
||||||
from .._init_options import is_ai_skills_enabled
|
from .._init_options import is_ai_skills_enabled
|
||||||
from ..integrations.base import IntegrationBase
|
from ..integrations.base import IntegrationBase
|
||||||
|
from .._utils import dump_frontmatter
|
||||||
|
|
||||||
|
|
||||||
def _substitute_core_template(
|
def _substitute_core_template(
|
||||||
@@ -1068,7 +1069,7 @@ class PresetManager:
|
|||||||
skill_name, desc,
|
skill_name, desc,
|
||||||
f"override:{cmd_name}",
|
f"override:{cmd_name}",
|
||||||
)
|
)
|
||||||
fm_text = yaml.safe_dump(fm_data, sort_keys=False).strip()
|
fm_text = dump_frontmatter(fm_data)
|
||||||
skill_title = self._skill_title_from_command(cmd_name)
|
skill_title = self._skill_title_from_command(cmd_name)
|
||||||
skill_content = (
|
skill_content = (
|
||||||
f"---\n{fm_text}\n---\n\n"
|
f"---\n{fm_text}\n---\n\n"
|
||||||
@@ -1345,7 +1346,7 @@ class PresetManager:
|
|||||||
enhanced_desc,
|
enhanced_desc,
|
||||||
f"preset:{manifest.id}",
|
f"preset:{manifest.id}",
|
||||||
)
|
)
|
||||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
frontmatter_text = dump_frontmatter(frontmatter_data)
|
||||||
skill_content = (
|
skill_content = (
|
||||||
f"---\n"
|
f"---\n"
|
||||||
f"{frontmatter_text}\n"
|
f"{frontmatter_text}\n"
|
||||||
@@ -1441,7 +1442,7 @@ class PresetManager:
|
|||||||
enhanced_desc,
|
enhanced_desc,
|
||||||
f"templates/commands/{short_name}.md",
|
f"templates/commands/{short_name}.md",
|
||||||
)
|
)
|
||||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
frontmatter_text = dump_frontmatter(frontmatter_data)
|
||||||
skill_title = self._skill_title_from_command(short_name)
|
skill_title = self._skill_title_from_command(short_name)
|
||||||
skill_content = (
|
skill_content = (
|
||||||
f"---\n"
|
f"---\n"
|
||||||
@@ -1478,7 +1479,7 @@ class PresetManager:
|
|||||||
frontmatter.get("description", f"Extension command: {command_name}"),
|
frontmatter.get("description", f"Extension command: {command_name}"),
|
||||||
extension_restore["source"],
|
extension_restore["source"],
|
||||||
)
|
)
|
||||||
frontmatter_text = yaml.safe_dump(frontmatter_data, sort_keys=False).strip()
|
frontmatter_text = dump_frontmatter(frontmatter_data)
|
||||||
skill_content = (
|
skill_content = (
|
||||||
f"---\n"
|
f"---\n"
|
||||||
f"{frontmatter_text}\n"
|
f"{frontmatter_text}\n"
|
||||||
@@ -3276,7 +3277,7 @@ class PresetResolver:
|
|||||||
if top_fm:
|
if top_fm:
|
||||||
top_frontmatter_text = (
|
top_frontmatter_text = (
|
||||||
"---\n"
|
"---\n"
|
||||||
+ yaml.safe_dump(top_fm, sort_keys=False).strip()
|
+ dump_frontmatter(top_fm)
|
||||||
+ "\n---"
|
+ "\n---"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -49,6 +50,23 @@ class ShellStep(StepBase):
|
|||||||
error=f"Shell command exited with code {proc.returncode}.",
|
error=f"Shell command exited with code {proc.returncode}.",
|
||||||
output=output,
|
output=output,
|
||||||
)
|
)
|
||||||
|
if config.get("output_format") == "json":
|
||||||
|
# Opt-in structured output: expose the parsed stdout under
|
||||||
|
# ``output.data`` so later steps can consume typed values
|
||||||
|
# (e.g. a fan-out's ``items:``). A parse failure fails the
|
||||||
|
# step — declaring ``output_format: json`` is a contract.
|
||||||
|
try:
|
||||||
|
output["data"] = json.loads(proc.stdout)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return StepResult(
|
||||||
|
status=StepStatus.FAILED,
|
||||||
|
error=(
|
||||||
|
f"Shell step {config.get('id', '?')!r} declared "
|
||||||
|
f"output_format: json but stdout is not valid "
|
||||||
|
f"JSON: {exc}"
|
||||||
|
),
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
return StepResult(
|
return StepResult(
|
||||||
status=StepStatus.COMPLETED,
|
status=StepStatus.COMPLETED,
|
||||||
output=output,
|
output=output,
|
||||||
@@ -72,4 +90,10 @@ class ShellStep(StepBase):
|
|||||||
errors.append(
|
errors.append(
|
||||||
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
|
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
|
||||||
)
|
)
|
||||||
|
output_format = config.get("output_format")
|
||||||
|
if output_format is not None and output_format != "json":
|
||||||
|
errors.append(
|
||||||
|
f"Shell step {config.get('id', '?')!r}: 'output_format' must "
|
||||||
|
f"be 'json' when present, got {output_format!r}."
|
||||||
|
)
|
||||||
return errors
|
return errors
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ class TestClaudeIntegration:
|
|||||||
assert parsed["disable-model-invocation"] is False
|
assert parsed["disable-model-invocation"] is False
|
||||||
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
|
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
|
||||||
|
|
||||||
|
def test_render_skill_unicode(self):
|
||||||
|
"""Test rendering a skill preserves non-ASCII characters."""
|
||||||
|
integration = get_integration("claude")
|
||||||
|
rendered = integration._render_skill(
|
||||||
|
"constitution",
|
||||||
|
{"description": "Prüfe Konformität der Implementierung"},
|
||||||
|
"Body",
|
||||||
|
)
|
||||||
|
assert "Prüfe Konformität" in rendered
|
||||||
|
|
||||||
def test_setup_upserts_context_section(self, tmp_path):
|
def test_setup_upserts_context_section(self, tmp_path):
|
||||||
integration = get_integration("claude")
|
integration = get_integration("claude")
|
||||||
manifest = IntegrationManifest("claude", tmp_path)
|
manifest = IntegrationManifest("claude", tmp_path)
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
|
|||||||
}
|
}
|
||||||
|
|
||||||
with open(ext_dir / "extension.yml", "w") as f:
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
yaml.dump(manifest_data, f)
|
yaml.safe_dump(manifest_data, f)
|
||||||
|
|
||||||
commands_dir = ext_dir / "commands"
|
commands_dir = ext_dir / "commands"
|
||||||
commands_dir.mkdir()
|
commands_dir.mkdir()
|
||||||
@@ -119,6 +119,50 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
|
|||||||
return ext_dir
|
return ext_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Path:
|
||||||
|
"""Create an extension whose command description contains non-ASCII characters."""
|
||||||
|
ext_dir = temp_dir / ext_id
|
||||||
|
ext_dir.mkdir()
|
||||||
|
description = "Prüfe Konformität der Implementierung"
|
||||||
|
|
||||||
|
manifest_data = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"extension": {
|
||||||
|
"id": ext_id,
|
||||||
|
"name": "Unicode Extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": description,
|
||||||
|
},
|
||||||
|
"requires": {"speckit_version": ">=0.1.0"},
|
||||||
|
"provides": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": f"speckit.{ext_id}.hello",
|
||||||
|
"file": "commands/hello.md",
|
||||||
|
"description": description,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
|
||||||
|
yaml.safe_dump(manifest_data, f, allow_unicode=True)
|
||||||
|
|
||||||
|
commands_dir = ext_dir / "commands"
|
||||||
|
commands_dir.mkdir()
|
||||||
|
(commands_dir / "hello.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
f'description: "{description}"\n'
|
||||||
|
"---\n"
|
||||||
|
"\n"
|
||||||
|
"# Hello\n"
|
||||||
|
"\n"
|
||||||
|
"Body.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return ext_dir
|
||||||
|
|
||||||
|
|
||||||
def _can_create_symlink(temp_dir: Path) -> bool:
|
def _can_create_symlink(temp_dir: Path) -> bool:
|
||||||
"""Return True when the current platform/user can create file symlinks."""
|
"""Return True when the current platform/user can create file symlinks."""
|
||||||
target = temp_dir / "symlink-target.txt"
|
target = temp_dir / "symlink-target.txt"
|
||||||
@@ -432,6 +476,18 @@ class TestExtensionSkillRegistration:
|
|||||||
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
|
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
|
||||||
assert "argument-hint" not in parsed
|
assert "argument-hint" not in parsed
|
||||||
|
|
||||||
|
def test_skill_md_unicode(self, skills_project, temp_dir):
|
||||||
|
"""SKILL.md generation should preserve non-ASCII characters."""
|
||||||
|
project_dir, skills_dir = skills_project
|
||||||
|
ext_dir = _create_unicode_extension_dir(temp_dir)
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||||
|
|
||||||
|
skill_file = skills_dir / "speckit-uni-ext-hello" / "SKILL.md"
|
||||||
|
content = skill_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "Prüfe Konformität" in content
|
||||||
|
|
||||||
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
|
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
|
||||||
"""No skills should be created when ai_skills is false."""
|
"""No skills should be created when ai_skills is false."""
|
||||||
manager = ExtensionManager(no_skills_project)
|
manager = ExtensionManager(no_skills_project)
|
||||||
@@ -692,7 +748,7 @@ class TestExtensionSkillRegistration:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
with open(ext_dir / "extension.yml", "w") as f:
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
yaml.dump(manifest_data, f)
|
yaml.safe_dump(manifest_data, f)
|
||||||
|
|
||||||
(ext_dir / "commands").mkdir()
|
(ext_dir / "commands").mkdir()
|
||||||
(ext_dir / "commands" / "plan.md").write_text(
|
(ext_dir / "commands" / "plan.md").write_text(
|
||||||
@@ -747,7 +803,7 @@ class TestExtensionSkillRegistration:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
with open(ext_dir / "extension.yml", "w") as f:
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
yaml.dump(manifest_data, f)
|
yaml.safe_dump(manifest_data, f)
|
||||||
|
|
||||||
(ext_dir / "commands").mkdir()
|
(ext_dir / "commands").mkdir()
|
||||||
(ext_dir / "commands" / "exists.md").write_text(
|
(ext_dir / "commands" / "exists.md").write_text(
|
||||||
@@ -1303,7 +1359,7 @@ class TestExtensionSkillEdgeCases:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
with open(ext_dir / "extension.yml", "w") as f:
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
yaml.dump(manifest_data, f)
|
yaml.safe_dump(manifest_data, f)
|
||||||
|
|
||||||
(ext_dir / "commands").mkdir()
|
(ext_dir / "commands").mkdir()
|
||||||
(ext_dir / "commands" / "plain.md").write_text(
|
(ext_dir / "commands" / "plain.md").write_text(
|
||||||
@@ -1390,7 +1446,7 @@ class TestExtensionSkillEdgeCases:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
with open(ext_dir / "extension.yml", "w") as f:
|
with open(ext_dir / "extension.yml", "w") as f:
|
||||||
yaml.dump(manifest_data, f)
|
yaml.safe_dump(manifest_data, f)
|
||||||
|
|
||||||
(ext_dir / "commands").mkdir()
|
(ext_dir / "commands").mkdir()
|
||||||
# Malformed YAML: invalid key-value syntax
|
# Malformed YAML: invalid key-value syntax
|
||||||
|
|||||||
@@ -1118,6 +1118,56 @@ class TestExtensionManager:
|
|||||||
assert manifest.id == "test-ext"
|
assert manifest.id == "test-ext"
|
||||||
assert manager.registry.is_installed("test-ext")
|
assert manager.registry.is_installed("test-ext")
|
||||||
|
|
||||||
|
def test_install_from_install_dir_is_rejected_without_data_loss(
|
||||||
|
self, extension_dir, project_dir
|
||||||
|
):
|
||||||
|
"""Installing from an extension's own install dir must fail without
|
||||||
|
deleting it (regression for issue #2990)."""
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
|
||||||
|
# Install once so the extension lives at its install destination.
|
||||||
|
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||||
|
install_dir = project_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
assert install_dir.exists()
|
||||||
|
|
||||||
|
# Re-installing from that same directory with --force must be rejected.
|
||||||
|
with pytest.raises(ValidationError, match="install destination"):
|
||||||
|
manager.install_from_directory(
|
||||||
|
install_dir, "0.1.0", register_commands=False, force=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# The directory and its contents must be left intact (no data loss).
|
||||||
|
assert install_dir.exists()
|
||||||
|
assert (install_dir / "extension.yml").exists()
|
||||||
|
assert (install_dir / "commands" / "hello.md").exists()
|
||||||
|
|
||||||
|
def test_install_from_install_dir_is_rejected_when_resolve_fails(
|
||||||
|
self, extension_dir, project_dir, monkeypatch
|
||||||
|
):
|
||||||
|
"""Resolution failures must not bypass the self-install guard."""
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
|
||||||
|
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||||
|
install_dir = project_dir / ".specify" / "extensions" / "test-ext"
|
||||||
|
|
||||||
|
original_resolve = Path.resolve
|
||||||
|
|
||||||
|
def fail_resolve(self, *args, **kwargs):
|
||||||
|
if self in {install_dir, manager.extensions_dir / "test-ext"}:
|
||||||
|
raise OSError("cannot resolve path")
|
||||||
|
return original_resolve(self, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "resolve", fail_resolve)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="install destination"):
|
||||||
|
manager.install_from_directory(
|
||||||
|
install_dir, "0.1.0", register_commands=False, force=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert install_dir.exists()
|
||||||
|
assert (install_dir / "extension.yml").exists()
|
||||||
|
assert (install_dir / "commands" / "hello.md").exists()
|
||||||
|
|
||||||
def test_install_zip_force_reinstall(self, extension_dir, project_dir):
|
def test_install_zip_force_reinstall(self, extension_dir, project_dir):
|
||||||
"""Test force-reinstalling from ZIP when already installed."""
|
"""Test force-reinstalling from ZIP when already installed."""
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|||||||
90
tests/test_live_transient_windows.py
Normal file
90
tests/test_live_transient_windows.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""Tests for Rich Live transient=False on Windows (GitHub issue #2927).
|
||||||
|
|
||||||
|
PowerShell 5.1's legacy console host does not support VT escape sequences
|
||||||
|
reliably. Rich's ``Live(transient=True)`` attempts cursor restoration on
|
||||||
|
exit, which hangs indefinitely on that console. The fix disables transient
|
||||||
|
mode when ``sys.platform == "win32"``.
|
||||||
|
|
||||||
|
These tests patch ``sys.platform`` and intercept the ``Live`` constructor
|
||||||
|
to verify the correct ``transient`` value reaches Rich.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _console.py — Live in the select_with_arrows helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _invoke_select_with_arrows(platform: str) -> bool:
|
||||||
|
"""Patch sys.platform and Live, invoke select_with_arrows, return transient kwarg."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
mock_live_instance = MagicMock()
|
||||||
|
mock_live_instance.__enter__ = MagicMock(return_value=mock_live_instance)
|
||||||
|
mock_live_instance.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
def fake_live(*args, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
return mock_live_instance
|
||||||
|
|
||||||
|
# Patch readchar so the loop immediately returns "enter"
|
||||||
|
import readchar
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("sys.platform", platform),
|
||||||
|
patch("specify_cli._console.Live", side_effect=fake_live),
|
||||||
|
patch("specify_cli._console.readchar.readkey", return_value=readchar.key.ENTER),
|
||||||
|
):
|
||||||
|
from specify_cli._console import select_with_arrows
|
||||||
|
|
||||||
|
select_with_arrows({"a": "Option A", "b": "Option B"}, "Pick one", "a")
|
||||||
|
|
||||||
|
return captured["transient"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelectWithArrowsLiveTransient:
|
||||||
|
"""Verify that select_with_arrows passes transient=False on Windows."""
|
||||||
|
|
||||||
|
def test_transient_false_on_windows(self):
|
||||||
|
assert _invoke_select_with_arrows("win32") is False
|
||||||
|
|
||||||
|
def test_transient_true_on_linux(self):
|
||||||
|
assert _invoke_select_with_arrows("linux") is True
|
||||||
|
|
||||||
|
def test_transient_true_on_macos(self):
|
||||||
|
assert _invoke_select_with_arrows("darwin") is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# init.py — verify source contains the platform guard (regression check)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceContainsPlatformGuard:
|
||||||
|
"""Ensure the platform guard feeds into the Live() transient kwarg."""
|
||||||
|
|
||||||
|
# Single DOTALL regex: _transient assigned from win32 check, then used in Live()
|
||||||
|
_GUARD_RE = r"_transient\s*=\s*sys\.platform\s*!=\s*['\"]win32['\"].*Live\(.*transient\s*=\s*_transient"
|
||||||
|
|
||||||
|
def test_init_has_win32_guard(self):
|
||||||
|
"""init.py must assign _transient from platform check and pass it to Live."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
init_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "commands" / "init.py"
|
||||||
|
content = init_src.read_text(encoding="utf-8")
|
||||||
|
assert re.search(self._GUARD_RE, content, re.DOTALL)
|
||||||
|
|
||||||
|
def test_console_has_win32_guard(self):
|
||||||
|
"""_console.py must assign _transient from platform check and pass it to Live."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
console_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "_console.py"
|
||||||
|
content = console_src.read_text(encoding="utf-8")
|
||||||
|
assert re.search(self._GUARD_RE, content, re.DOTALL)
|
||||||
|
assert re.search(r"transient\s*=\s*_transient", content)
|
||||||
|
assert "transient=_transient" in content
|
||||||
@@ -162,7 +162,9 @@ class TestWorkflowRunWithoutProject:
|
|||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"workflow run failed unexpectedly: {result.output}"
|
# A failed workflow now maps to a non-zero process exit code so
|
||||||
|
# scripts and CI can rely on $? (the CLI itself still ran fine).
|
||||||
|
assert result.exit_code == 1, f"expected exit 1 on failed run: {result.output}"
|
||||||
assert "Status: failed" in result.output
|
assert "Status: failed" in result.output
|
||||||
|
|
||||||
def test_workflow_run_yaml_rejects_symlinked_specify_dir(self, tmp_path):
|
def test_workflow_run_yaml_rejects_symlinked_specify_dir(self, tmp_path):
|
||||||
|
|||||||
@@ -912,6 +912,17 @@ class TestPromptStep:
|
|||||||
class TestShellStep:
|
class TestShellStep:
|
||||||
"""Test the shell step type."""
|
"""Test the shell step type."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _python_run(tmp_path, body):
|
||||||
|
"""A portable shell ``run`` that executes ``body`` with the current
|
||||||
|
interpreter, avoiding non-portable shell quoting (e.g. Windows
|
||||||
|
``cmd.exe`` keeping single quotes) in the output_format tests."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
script = tmp_path / "emit.py"
|
||||||
|
script.write_text(body, encoding="utf-8")
|
||||||
|
return f'"{sys.executable}" "{script}"'
|
||||||
|
|
||||||
def test_execute_echo(self):
|
def test_execute_echo(self):
|
||||||
from specify_cli.workflows.steps.shell import ShellStep
|
from specify_cli.workflows.steps.shell import ShellStep
|
||||||
from specify_cli.workflows.base import StepContext, StepStatus
|
from specify_cli.workflows.base import StepContext, StepStatus
|
||||||
@@ -944,6 +955,62 @@ class TestShellStep:
|
|||||||
assert any("missing 'run'" in e for e in errors)
|
assert any("missing 'run'" in e for e in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_format_json_exposes_data(self, tmp_path):
|
||||||
|
from specify_cli.workflows.steps.shell import ShellStep
|
||||||
|
from specify_cli.workflows.base import StepContext, StepStatus
|
||||||
|
|
||||||
|
step = ShellStep()
|
||||||
|
ctx = StepContext(project_root=str(tmp_path))
|
||||||
|
config = {
|
||||||
|
"id": "emit",
|
||||||
|
"run": self._python_run(
|
||||||
|
tmp_path, 'import json; print(json.dumps({"items": [1, 2]}))\n'
|
||||||
|
),
|
||||||
|
"output_format": "json",
|
||||||
|
}
|
||||||
|
result = step.execute(config, ctx)
|
||||||
|
assert result.status == StepStatus.COMPLETED
|
||||||
|
assert result.output["data"] == {"items": [1, 2]}
|
||||||
|
assert result.output["exit_code"] == 0 # raw keys still present
|
||||||
|
|
||||||
|
def test_output_format_json_invalid_stdout_fails(self, tmp_path):
|
||||||
|
from specify_cli.workflows.steps.shell import ShellStep
|
||||||
|
from specify_cli.workflows.base import StepContext, StepStatus
|
||||||
|
|
||||||
|
step = ShellStep()
|
||||||
|
ctx = StepContext(project_root=str(tmp_path))
|
||||||
|
config = {
|
||||||
|
"id": "emit",
|
||||||
|
"run": self._python_run(tmp_path, "print('not-json')\n"),
|
||||||
|
"output_format": "json",
|
||||||
|
}
|
||||||
|
result = step.execute(config, ctx)
|
||||||
|
assert result.status == StepStatus.FAILED
|
||||||
|
assert "output_format: json" in (result.error or "")
|
||||||
|
|
||||||
|
def test_no_output_format_keeps_raw_output_only(self, tmp_path):
|
||||||
|
from specify_cli.workflows.steps.shell import ShellStep
|
||||||
|
from specify_cli.workflows.base import StepContext, StepStatus
|
||||||
|
|
||||||
|
step = ShellStep()
|
||||||
|
ctx = StepContext(project_root=str(tmp_path))
|
||||||
|
config = {
|
||||||
|
"id": "emit",
|
||||||
|
"run": self._python_run(
|
||||||
|
tmp_path, 'import json; print(json.dumps({"items": []}))\n'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
result = step.execute(config, ctx)
|
||||||
|
assert result.status == StepStatus.COMPLETED
|
||||||
|
assert "data" not in result.output
|
||||||
|
|
||||||
|
def test_validate_rejects_unknown_output_format(self):
|
||||||
|
from specify_cli.workflows.steps.shell import ShellStep
|
||||||
|
|
||||||
|
step = ShellStep()
|
||||||
|
errors = step.validate({"id": "emit", "run": "exit 0", "output_format": "yaml"})
|
||||||
|
assert any("'output_format' must be 'json'" in e for e in errors)
|
||||||
|
|
||||||
class _StubStdin:
|
class _StubStdin:
|
||||||
"""Stdin stub exposing only a fixed ``isatty`` result.
|
"""Stdin stub exposing only a fixed ``isatty`` result.
|
||||||
|
|
||||||
@@ -4964,3 +5031,95 @@ steps:
|
|||||||
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
||||||
assert len(asset_calls) >= 1
|
assert len(asset_calls) >= 1
|
||||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkflowRunExitCodes:
|
||||||
|
"""CLI-level tests for the run/resume process exit codes."""
|
||||||
|
|
||||||
|
_WF_OK = """
|
||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "exit-ok"
|
||||||
|
name: "Exit OK"
|
||||||
|
version: "1.0.0"
|
||||||
|
steps:
|
||||||
|
- id: fine
|
||||||
|
type: shell
|
||||||
|
run: "exit 0"
|
||||||
|
"""
|
||||||
|
|
||||||
|
_WF_FAIL = """
|
||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "exit-fail"
|
||||||
|
name: "Exit Fail"
|
||||||
|
version: "1.0.0"
|
||||||
|
steps:
|
||||||
|
- id: boom
|
||||||
|
type: shell
|
||||||
|
run: "exit 1"
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _write(self, tmp_path, content):
|
||||||
|
path = tmp_path / "wf.yml"
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_run_completed_exits_zero(self, tmp_path, monkeypatch):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, ["workflow", "run", str(self._write(tmp_path, self._WF_OK))])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Status: completed" in result.stdout
|
||||||
|
|
||||||
|
def test_run_failed_exits_nonzero(self, tmp_path, monkeypatch):
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(app, ["workflow", "run", str(self._write(tmp_path, self._WF_FAIL))])
|
||||||
|
assert "Status: failed" in result.stdout
|
||||||
|
assert result.exit_code == 1
|
||||||
|
|
||||||
|
def test_run_failed_exits_nonzero_with_json(self, tmp_path, monkeypatch):
|
||||||
|
import json as _json
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["workflow", "run", str(self._write(tmp_path, self._WF_FAIL)), "--json"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1, result.stdout
|
||||||
|
payload = _json.loads(result.stdout)
|
||||||
|
assert payload["status"] == "failed"
|
||||||
|
|
||||||
|
def test_resume_failed_run_exits_nonzero(self, tmp_path, monkeypatch):
|
||||||
|
# End-to-end coverage for the `workflow resume` exit-code mapping:
|
||||||
|
# resuming a run whose outcome is still `failed` must exit non-zero,
|
||||||
|
# mirroring `workflow run`. Resume re-executes the failed step, which
|
||||||
|
# fails again, so the resumed outcome stays `failed`.
|
||||||
|
import json as _json
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
from specify_cli import app
|
||||||
|
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
(tmp_path / ".specify").mkdir() # `workflow resume` requires a project
|
||||||
|
runner = CliRunner()
|
||||||
|
run = runner.invoke(
|
||||||
|
app,
|
||||||
|
["workflow", "run", str(self._write(tmp_path, self._WF_FAIL)), "--json"],
|
||||||
|
)
|
||||||
|
assert run.exit_code == 1, run.stdout
|
||||||
|
run_id = _json.loads(run.stdout)["run_id"]
|
||||||
|
|
||||||
|
resumed = runner.invoke(app, ["workflow", "resume", run_id, "--json"])
|
||||||
|
assert resumed.exit_code == 1, resumed.stdout
|
||||||
|
payload = _json.loads(resumed.stdout)
|
||||||
|
assert payload["status"] == "failed"
|
||||||
|
|||||||
Reference in New Issue
Block a user