mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92fb6e8eda |
2
.github/workflows/publish-pypi.yml
vendored
2
.github/workflows/publish-pypi.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -2,24 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.11.9] - 2026-06-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Docs: add cline and zcode to multi-install-safe table (#3180)
|
||||
- Docs: document missing flags --force and --refresh-shared-infra (#3179)
|
||||
- fix(claude): stop forking /speckit-analyze to prevent long-session freezes (#3188)
|
||||
- fix: derive plan path from feature.json in update-agent-context (#3069)
|
||||
- fix(catalog): companion → README docs, version-pinned download URL, v0.11.0, refreshed tags (#2954)
|
||||
- chore(deps): bump actions/setup-python from 6.2.0 to 6.3.0 (#3173)
|
||||
- Update SicarioSpec Core preset to v0.5.1 (#3165)
|
||||
- fix(extensions,presets,workflows): resolve private GHES release assets via /api/v3 (#3157)
|
||||
- Update preset composition strategy reference (#3143)
|
||||
- fix(scripts): keep PowerShell branch-name acronym match case-sensitive (parity with bash) (#3129)
|
||||
- fix(extensions): tell agent to run mandatory hooks, not just emit the directive (#2901)
|
||||
- Point sicario-core docs to preset README (#3120)
|
||||
- chore: release 0.11.8, begin 0.11.9.dev0 development (#3156)
|
||||
|
||||
## [0.11.8] - 2026-06-24
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -25,7 +25,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| SicarioSpec Core | Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
||||
|
||||
@@ -69,33 +69,6 @@ Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes.
|
||||
}
|
||||
```
|
||||
|
||||
### GitHub Enterprise Server (GHES)
|
||||
|
||||
To use a private catalog or extension hosted on a GitHub Enterprise Server
|
||||
instance, add a `github` entry listing your GHES host(s). The same entry
|
||||
authenticates both catalog JSON fetches **and** private release-asset
|
||||
downloads — Specify recognizes the listed hosts as GitHub Enterprise and
|
||||
resolves release downloads through the GHES REST API (`/api/v3`).
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"hosts": ["ghes.example.com", "raw.ghes.example.com", "codeload.ghes.example.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_ENTERPRISE_TOKEN"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
List the **bare** web host (e.g. `ghes.example.com`) — release-download URLs
|
||||
live there. If your instance uses subdomain isolation, also list the `raw.`
|
||||
and `codeload.` subdomains your catalog/extension URLs use. A
|
||||
`*.ghes.example.com` wildcard matches subdomains but **not** the bare host,
|
||||
so always include the bare host explicitly.
|
||||
|
||||
### Azure DevOps (`azure-devops`)
|
||||
|
||||
| Scheme | Header | Use for |
|
||||
|
||||
@@ -26,7 +26,6 @@ specify extension add <name>
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| `--dev` | Install from a local directory (for development) |
|
||||
| `--from <url>` | Install from a custom URL instead of the catalog |
|
||||
| `--force` | Overwrite if already installed |
|
||||
| `--priority <N>`| Resolution priority (default: 10; lower = higher precedence) |
|
||||
|
||||
Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration.
|
||||
|
||||
@@ -100,7 +100,6 @@ specify integration switch <key>
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
|
||||
| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) |
|
||||
| `--integration-options` | Options for the target integration when it is not already installed |
|
||||
|
||||
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`.
|
||||
@@ -185,7 +184,6 @@ The currently declared multi-install safe integrations are:
|
||||
| --- | --------- |
|
||||
| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` |
|
||||
| `claude` | `.claude/skills`, `CLAUDE.md` |
|
||||
| `cline` | `.clinerules/workflows`, `.clinerules/specify-rules.md` |
|
||||
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
|
||||
| `codex` | `.agents/skills`, `AGENTS.md` |
|
||||
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
|
||||
@@ -201,7 +199,6 @@ The currently declared multi-install safe integrations are:
|
||||
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
|
||||
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
|
||||
| `windsurf` | `.windsurf/workflows`, `.windsurf/rules/specify-rules.md` |
|
||||
| `zcode` | `.zcode/skills`, `ZCODE.md` |
|
||||
|
||||
Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
|
||||
|
||||
|
||||
@@ -137,11 +137,9 @@ catalogs:
|
||||
|
||||
## File Resolution
|
||||
|
||||
Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers.
|
||||
Presets can provide command files, template files (like `plan-template.md`), and script files. These are resolved at runtime using a **replace** strategy — the first match in the priority stack wins and is used entirely. Each file is looked up independently, so different files can come from different layers.
|
||||
|
||||
Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into detected agent directories instead of being re-resolved by agents. During preset install, Spec Kit registers command files for the preset being installed; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Agents do not re-resolve the stack each time they run a command.
|
||||
|
||||
By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder.
|
||||
> **Note:** Additional composition strategies (`append`, `prepend`, `wrap`) are planned for a future release.
|
||||
|
||||
The resolution stack, from highest to lowest precedence:
|
||||
|
||||
@@ -150,6 +148,8 @@ The resolution stack, from highest to lowest precedence:
|
||||
3. **Installed extensions** — sorted by priority
|
||||
4. **Spec Kit core** — `.specify/templates/`
|
||||
|
||||
Commands are registered at install time (not resolved through the stack at runtime).
|
||||
|
||||
### Resolution Stack
|
||||
|
||||
```mermaid
|
||||
@@ -215,7 +215,7 @@ Run `specify preset resolve <name>` to trace the resolution stack and see which
|
||||
|
||||
### What's the difference between disabling and removing a preset?
|
||||
|
||||
**Disabling** (`specify preset disable`) keeps the preset installed but excludes it from future template and script resolution. Previously registered commands remain available in your AI coding agent until preset removal, so use removal when you need command changes to stop taking effect. Disabling is useful for temporarily testing template/script behavior without a preset, or comparing template/script output with and without it. Re-enable anytime with `specify preset enable`.
|
||||
**Disabling** (`specify preset disable`) keeps the preset installed but excludes its files from the resolution stack. Commands the preset registered remain available in your AI coding agent. This is useful for temporarily testing behavior without a preset, or comparing output with and without it. Re-enable anytime with `specify preset enable`.
|
||||
|
||||
**Removing** (`specify preset remove`) fully uninstalls the preset — deletes its files, unregisters its commands from your AI coding agent, and removes it from the registry.
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
#
|
||||
# Usage: update-agent-context.sh [plan_path]
|
||||
#
|
||||
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
|
||||
# (written by /speckit-specify). Falls back to the most recently modified
|
||||
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
|
||||
# When `plan_path` is omitted, the script picks the most recently modified
|
||||
# `specs/*/plan.md` if any exist, otherwise emits the section without a
|
||||
# concrete plan path.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -202,78 +202,23 @@ unset _cf_parts _seg
|
||||
|
||||
PLAN_PATH="${1:-}"
|
||||
if [[ -z "$PLAN_PATH" ]]; then
|
||||
# Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic.
|
||||
_feature_json="$PROJECT_ROOT/.specify/feature.json"
|
||||
if [[ -f "$_feature_json" ]]; then
|
||||
_feature_dir="$("$_python" - "$_feature_json" <<'PY'
|
||||
import sys, json
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as fh:
|
||||
d = json.load(fh)
|
||||
val = d.get("feature_directory", "")
|
||||
print(val if isinstance(val, str) else "")
|
||||
except Exception:
|
||||
print("")
|
||||
PY
|
||||
)"
|
||||
# Normalize backslashes (written by PS on Windows) to forward slashes before path ops.
|
||||
_feature_dir="$(printf '%s' "$_feature_dir" | tr '\\' '/')"
|
||||
_feature_dir="${_feature_dir%/}"
|
||||
if [[ -n "$_feature_dir" ]]; then
|
||||
# feature_directory may be relative or absolute (absolute paths outside PROJECT_ROOT
|
||||
# are preserved as-is by _persist_feature_json in common.sh).
|
||||
# Also match drive-qualified paths (C:/...) written by PowerShell on Windows.
|
||||
if [[ "$_feature_dir" == /* ]] || [[ "$_feature_dir" =~ ^[A-Za-z]:/ ]]; then
|
||||
_candidate="$_feature_dir/plan.md"
|
||||
else
|
||||
_candidate="$PROJECT_ROOT/$_feature_dir/plan.md"
|
||||
fi
|
||||
if [[ -f "$_candidate" ]]; then
|
||||
# Resolve symlinks before comparing so paths like /var/… vs /private/var/…
|
||||
# (macOS) are treated as equivalent. Mirrors the mtime-fallback approach.
|
||||
PLAN_PATH="$("$_python" - "$PROJECT_ROOT" "$_candidate" <<'PY'
|
||||
import sys
|
||||
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
|
||||
# Use find + sort by modification time to avoid ls/head fragility with
|
||||
# spaces in paths or SIGPIPE from pipefail.
|
||||
_plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
|
||||
import sys, os
|
||||
from pathlib import Path
|
||||
root = Path(sys.argv[1]).resolve()
|
||||
cand = Path(sys.argv[2]).resolve()
|
||||
try:
|
||||
print(cand.relative_to(root).as_posix())
|
||||
except ValueError:
|
||||
# Outside project root: emit the resolved path in POSIX form.
|
||||
# as_posix() converts backslashes correctly on native Windows Python.
|
||||
print(cand.as_posix())
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fall back to mtime only when feature.json is absent or its plan does not exist yet.
|
||||
# Python emits a project-relative POSIX path directly to avoid bash prefix-strip
|
||||
# issues with backslash paths on Windows (Git bash / MSYS2).
|
||||
if [[ -z "$PLAN_PATH" ]]; then
|
||||
_plan_rel="$("$_python" - "$PROJECT_ROOT" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
root = Path(sys.argv[1]).resolve()
|
||||
specs = root / "specs"
|
||||
specs = Path(sys.argv[1]) / "specs"
|
||||
plans = sorted(
|
||||
specs.glob("*/plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if plans:
|
||||
try:
|
||||
print(plans[0].relative_to(root).as_posix())
|
||||
except ValueError:
|
||||
print("")
|
||||
else:
|
||||
print("")
|
||||
print(plans[0] if plans else "")
|
||||
PY
|
||||
)"
|
||||
if [[ -n "$_plan_rel" ]]; then
|
||||
PLAN_PATH="$_plan_rel"
|
||||
fi
|
||||
if [[ -n "$_plan_abs" ]]; then
|
||||
PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
# .specify/extensions/agent-context/agent-context-config.yml
|
||||
#
|
||||
# Usage: update-agent-context.ps1 [plan_path]
|
||||
#
|
||||
# When `plan_path` is omitted, the script derives it from `.specify/feature.json`
|
||||
# (written by /speckit-specify). Falls back to the most recently modified
|
||||
# `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
@@ -130,26 +126,14 @@ if (-not (Test-Path -LiteralPath $ExtConfig)) {
|
||||
$Options = $null
|
||||
if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
|
||||
try {
|
||||
$Options = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8 | ConvertFrom-Yaml -ErrorAction Stop
|
||||
$Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop
|
||||
} catch {
|
||||
# fall through to ConvertFrom-Json fallback
|
||||
# fall through to Python fallback
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $Options) {
|
||||
# ConvertFrom-Yaml unavailable or failed; try ConvertFrom-Json (no external deps,
|
||||
# works when the config file is valid JSON, which is a subset of YAML).
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $ExtConfig -Raw -Encoding UTF8
|
||||
$Options = $raw | ConvertFrom-Json -ErrorAction Stop
|
||||
if (-not (Test-ConfigObject -Object $Options)) { $Options = $null }
|
||||
} catch {
|
||||
$Options = $null
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $Options) {
|
||||
# ConvertFrom-Yaml/Json unavailable or failed; fall back to Python+PyYAML.
|
||||
# ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
|
||||
$pythonCmd = $null
|
||||
$pythonCandidates = @()
|
||||
if ($env:SPECKIT_PYTHON) {
|
||||
@@ -296,69 +280,21 @@ if ($cm) {
|
||||
}
|
||||
|
||||
if (-not $PlanPath) {
|
||||
# Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic.
|
||||
$FeatureJson = Join-Path $ProjectRoot '.specify/feature.json'
|
||||
if (Test-Path -LiteralPath $FeatureJson) {
|
||||
try {
|
||||
$fj = Get-Content -LiteralPath $FeatureJson -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$featureDir = $fj.feature_directory
|
||||
if ($featureDir -isnot [string] -or -not $featureDir) {
|
||||
$featureDir = $null
|
||||
} else {
|
||||
$featureDir = $featureDir.TrimEnd('\', '/')
|
||||
}
|
||||
if ($featureDir) {
|
||||
# Join-Path on Unix does not treat absolute ChildPath as "wins"; check explicitly.
|
||||
if ([System.IO.Path]::IsPathRooted($featureDir)) {
|
||||
$candidatePlan = Join-Path $featureDir 'plan.md'
|
||||
} else {
|
||||
$candidatePlan = Join-Path (Join-Path $ProjectRoot $featureDir) 'plan.md'
|
||||
}
|
||||
if (Test-Path -LiteralPath $candidatePlan) {
|
||||
# Resolve ./ .. segments before relativizing (mirrors bash Path.resolve()).
|
||||
# GetFullPath is available in .NET Framework 4.x (PS 5.1 compatible).
|
||||
$resolvedPlan = [System.IO.Path]::GetFullPath($candidatePlan)
|
||||
$resolvedDir = [System.IO.Path]::GetDirectoryName($resolvedPlan)
|
||||
$normRoot = $ProjectRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
||||
$normDir = $resolvedDir.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
||||
$cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
|
||||
if ($normDir.StartsWith($normRoot, $cmp)) {
|
||||
$relDir = $normDir.Substring($normRoot.Length).TrimEnd('\', '/')
|
||||
$PlanPath = if ($relDir) { $relDir.Replace('\', '/') + '/plan.md' } else { 'plan.md' }
|
||||
} else {
|
||||
$PlanPath = $resolvedPlan.Replace('\', '/')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Non-fatal: fall through to mtime heuristic.
|
||||
}
|
||||
}
|
||||
|
||||
# Fall back to mtime only when feature.json is absent or its plan does not exist yet.
|
||||
if (-not $PlanPath) {
|
||||
try {
|
||||
$specsDir = Join-Path $ProjectRoot 'specs'
|
||||
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_ } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($candidate) {
|
||||
# GetRelativePath is .NET 5+ only; strip prefix manually for PS 5.1 compat.
|
||||
# Use case-insensitive comparison on Windows only (matches common.ps1 pattern).
|
||||
$fullPath = $candidate.FullName.Replace('\', '/')
|
||||
$normRoot = $ProjectRoot.Replace('\', '/').TrimEnd('/') + '/'
|
||||
$cmp = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
|
||||
if ($fullPath.StartsWith($normRoot, $cmp)) {
|
||||
$PlanPath = $fullPath.Substring($normRoot.Length)
|
||||
} else {
|
||||
$PlanPath = $fullPath
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Non-fatal: continue without a plan path.
|
||||
# Discover plan.md exactly one level deep (specs/<feature>/plan.md),
|
||||
# matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
|
||||
# $ErrorActionPreference = 'Stop' don't abort the script.
|
||||
try {
|
||||
$specsDir = Join-Path $ProjectRoot 'specs'
|
||||
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_ } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($candidate) {
|
||||
$PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/')
|
||||
}
|
||||
} catch {
|
||||
# Non-fatal: continue without a plan path.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -772,40 +772,40 @@
|
||||
"companion": {
|
||||
"name": "SpecKit Companion",
|
||||
"id": "companion",
|
||||
"description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and composable commands you can customize with hooks and recipes.",
|
||||
"description": "Live spec-driven progress for SpecKit Companion — lifecycle capture, status, resume, and a turbo pipeline profile.",
|
||||
"author": "alfredoperez",
|
||||
"version": "0.11.0",
|
||||
"download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.11.0/companion-0.11.0.zip",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/alfredoperez/speckit-companion/releases/download/speckit-ext-v0.3.0/companion-0.3.0.zip",
|
||||
"repository": "https://github.com/alfredoperez/speckit-companion",
|
||||
"homepage": "https://github.com/alfredoperez/speckit-companion/tree/main/speckit-extension",
|
||||
"documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/README.md",
|
||||
"documentation": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/docs/",
|
||||
"changelog": "https://github.com/alfredoperez/speckit-companion/blob/main/speckit-extension/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "visibility",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.9.5",
|
||||
"speckit_version": ">=0.8.5",
|
||||
"tools": [
|
||||
{ "name": "python3", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 13,
|
||||
"commands": 10,
|
||||
"hooks": 4
|
||||
},
|
||||
"tags": [
|
||||
"vscode",
|
||||
"tracking",
|
||||
"companion",
|
||||
"progress",
|
||||
"status",
|
||||
"resume",
|
||||
"configurable",
|
||||
"extensible"
|
||||
"vscode",
|
||||
"lifecycle",
|
||||
"resume"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-11T00:00:00Z",
|
||||
"updated_at": "2026-06-24T00:00:00Z"
|
||||
"updated_at": "2026-06-11T00:00:00Z"
|
||||
},
|
||||
"conduct": {
|
||||
"name": "Conduct Extension",
|
||||
|
||||
@@ -252,10 +252,7 @@ function Get-BranchName {
|
||||
if ($stopWords -contains $word) { continue }
|
||||
if ($word.Length -ge 3) {
|
||||
$meaningfulWords += $word
|
||||
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
|
||||
# Case-sensitive (-cmatch) to mirror the bash twin's `grep -qw -- "${word^^}"`:
|
||||
# keep a short word only when its UPPERCASE form appears in the original
|
||||
# (an acronym). -match is case-insensitive and would keep every short word.
|
||||
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
||||
$meaningfulWords += $word
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-25T00:00:00Z",
|
||||
"updated_at": "2026-06-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -567,13 +567,13 @@
|
||||
"sicario-core": {
|
||||
"name": "SicarioSpec Core",
|
||||
"id": "sicario-core",
|
||||
"version": "0.5.1",
|
||||
"description": "Baseline secure-by-default Spec Kit governance profile.",
|
||||
"version": "0.4.0",
|
||||
"description": "Evidence-first security operations governance that maps feature risk to controls, gates, evidence, owners, approval, and accepted-risk decisions.",
|
||||
"author": "SicarioSpec Contributors",
|
||||
"repository": "https://github.com/dfirs1car1o/sicario-spec",
|
||||
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-core-0.5.1.zip",
|
||||
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.4.0/sicario-core-0.4.0.zip",
|
||||
"homepage": "https://github.com/dfirs1car1o/sicario-spec",
|
||||
"documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/presets/sicario-core/README.md",
|
||||
"documentation": "https://github.com/dfirs1car1o/sicario-spec/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.9.0"
|
||||
@@ -583,13 +583,14 @@
|
||||
"commands": 0
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"governance",
|
||||
"security-ops",
|
||||
"secure-by-default",
|
||||
"evidence"
|
||||
],
|
||||
"created_at": "2026-06-22T00:00:00Z",
|
||||
"updated_at": "2026-06-25T00:00:00Z"
|
||||
"updated_at": "2026-06-22T00:00:00Z"
|
||||
},
|
||||
"spec2cloud": {
|
||||
"name": "Spec2Cloud",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.11.9"
|
||||
version = "0.11.8"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1128,10 +1128,9 @@ def workflow_add(
|
||||
raise typer.Exit(1)
|
||||
|
||||
from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
|
||||
_wf_url_extra_headers = None
|
||||
_resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30, github_hosts=github_provider_hosts())
|
||||
_resolved_wf_url = _resolve_gh_asset(source, _open_url, timeout=30)
|
||||
if _resolved_wf_url:
|
||||
source = _resolved_wf_url
|
||||
_wf_url_extra_headers = {"Accept": "application/octet-stream"}
|
||||
@@ -1235,11 +1234,10 @@ def workflow_add(
|
||||
|
||||
try:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
from specify_cli._github_http import resolve_github_release_asset_api_url as _resolve_gh_asset
|
||||
|
||||
_wf_cat_extra_headers = None
|
||||
_resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30, github_hosts=github_provider_hosts())
|
||||
_resolved_workflow_url = _resolve_gh_asset(workflow_url, _open_url, timeout=30)
|
||||
if _resolved_workflow_url:
|
||||
workflow_url = _resolved_workflow_url
|
||||
_wf_cat_extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
@@ -10,7 +10,6 @@ through the config-driven helpers in :mod:`specify_cli.authentication.http`.
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
from fnmatch import fnmatch
|
||||
from typing import Callable, Dict, Optional
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
@@ -57,79 +56,55 @@ def build_github_request(url: str) -> urllib.request.Request:
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
def _host_matches(hostname: str, patterns: tuple[str, ...]) -> bool:
|
||||
"""Return True when *hostname* matches a pattern (exact or ``*.suffix``)."""
|
||||
hostname = hostname.lower()
|
||||
return any(p == hostname or fnmatch(hostname, p) for p in patterns)
|
||||
|
||||
|
||||
def resolve_github_release_asset_api_url(
|
||||
download_url: str,
|
||||
open_url_fn: Callable,
|
||||
timeout: int = 60,
|
||||
github_hosts: tuple[str, ...] = (),
|
||||
) -> Optional[str]:
|
||||
"""Resolve a GitHub release browser-download URL to its REST API asset URL.
|
||||
"""Resolve a GitHub browser release URL to its REST API asset URL.
|
||||
|
||||
Works for public ``github.com`` and for GitHub Enterprise Server (GHES)
|
||||
hosts. A host is treated as GHES when it matches one of *github_hosts*
|
||||
(exact hostname or ``*.suffix``) — supply the hosts the user has trusted
|
||||
under a ``github`` provider in ``auth.json``. This allowlist is the
|
||||
security gate: unlisted hosts never receive GHES API treatment, so a
|
||||
malicious catalog cannot induce an API request to an arbitrary host.
|
||||
For private or SSO-protected repositories, browser release download
|
||||
URLs (``https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>``)
|
||||
redirect to an HTML/SSO page instead of delivering the file. This
|
||||
helper resolves such a URL to the matching GitHub REST API asset URL
|
||||
(``https://api.github.com/repos/…/releases/assets/<id>``), which can
|
||||
then be downloaded with ``Accept: application/octet-stream`` and an
|
||||
auth token to retrieve the actual file payload.
|
||||
|
||||
For a public URL the API base is ``https://api.github.com``; for a GHES
|
||||
host it is ``{scheme}://{host[:port]}/api/v3``. Returns the API asset URL
|
||||
(downloadable with ``Accept: application/octet-stream`` + a token), the
|
||||
input unchanged if it is already an API asset URL, or ``None`` when the
|
||||
URL is not a resolvable GitHub release download or the lookup fails.
|
||||
If *download_url* is already a REST API asset URL, it is returned
|
||||
as-is. Non-GitHub URLs and GitHub URLs that are not release-download
|
||||
URLs return ``None``. If the API lookup fails (e.g. network error or
|
||||
asset not found), ``None`` is returned so callers can fall back to the
|
||||
original URL.
|
||||
|
||||
Args:
|
||||
download_url: The URL to resolve.
|
||||
open_url_fn: A callable compatible with
|
||||
``specify_cli.authentication.http.open_url`` used for the
|
||||
authenticated release-metadata lookup.
|
||||
``specify_cli.authentication.http.open_url`` used to make the
|
||||
authenticated API request.
|
||||
timeout: Per-request timeout in seconds.
|
||||
github_hosts: Host patterns to treat as GitHub Enterprise Server.
|
||||
|
||||
Returns:
|
||||
The resolved REST API asset URL, or ``None`` if resolution is not
|
||||
applicable or fails.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
|
||||
|
||||
is_ghes = (
|
||||
bool(hostname)
|
||||
and hostname not in GITHUB_HOSTS
|
||||
and _host_matches(hostname, github_hosts)
|
||||
)
|
||||
|
||||
def _is_asset_path(segments: list[str]) -> bool:
|
||||
return (
|
||||
len(segments) >= 6
|
||||
and segments[:1] == ["repos"]
|
||||
and segments[3:5] == ["releases", "assets"]
|
||||
)
|
||||
|
||||
# Already a REST API asset URL — use it directly. Pure passthrough induces
|
||||
# no new request: the caller fetches this same URL regardless, so it is
|
||||
# gated on path shape alone rather than the GHES allowlist. The token stays
|
||||
# independently gated by auth.json in the download helper, and only the
|
||||
# resolving path below (which issues a tag-lookup request) needs the
|
||||
# allowlist as its anti-SSRF gate.
|
||||
if hostname == "api.github.com" and _is_asset_path(parts):
|
||||
return download_url
|
||||
if hostname and parts[:2] == ["api", "v3"] and _is_asset_path(parts[2:]):
|
||||
# Already a REST API asset URL — use it directly
|
||||
if (
|
||||
parsed.hostname == "api.github.com"
|
||||
and len(parts) >= 6
|
||||
and parts[:1] == ["repos"]
|
||||
and parts[3:5] == ["releases", "assets"]
|
||||
):
|
||||
return download_url
|
||||
|
||||
# Determine the REST API base for browser release-download URLs.
|
||||
if hostname == "github.com":
|
||||
api_base = "https://api.github.com"
|
||||
elif is_ghes:
|
||||
authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}"
|
||||
api_base = f"{parsed.scheme}://{authority}/api/v3"
|
||||
else:
|
||||
# Only handle github.com browser release download URLs
|
||||
if parsed.hostname != "github.com":
|
||||
return None
|
||||
|
||||
# Expecting /<owner>/<repo>/releases/download/<tag>/<asset>
|
||||
@@ -139,7 +114,7 @@ def resolve_github_release_asset_api_url(
|
||||
owner, repo, tag = parts[0], parts[1], parts[4]
|
||||
asset_name = "/".join(parts[5:])
|
||||
encoded_tag = quote(tag, safe="")
|
||||
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
|
||||
release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
|
||||
|
||||
try:
|
||||
with open_url_fn(release_url, timeout=timeout) as response:
|
||||
|
||||
@@ -118,20 +118,6 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
|
||||
return urllib.request.Request(url, headers=headers)
|
||||
|
||||
|
||||
def github_provider_hosts() -> tuple[str, ...]:
|
||||
"""Return host patterns from every ``github`` provider entry in ``auth.json``.
|
||||
|
||||
Used to classify which hosts are GitHub Enterprise Server instances when
|
||||
resolving release-asset download URLs. Returns an empty tuple when no
|
||||
``auth.json`` exists or it contains no ``github`` entries.
|
||||
"""
|
||||
hosts: list[str] = []
|
||||
for entry in _load_config():
|
||||
if entry.provider == "github":
|
||||
hosts.extend(entry.hosts)
|
||||
return tuple(hosts)
|
||||
|
||||
|
||||
def open_url(
|
||||
url: str,
|
||||
timeout: int = 10,
|
||||
|
||||
@@ -2057,18 +2057,12 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
) -> Optional[str]:
|
||||
"""Resolve a GitHub release asset URL to its API asset URL.
|
||||
|
||||
Delegates to the shared helper in :mod:`specify_cli._github_http`,
|
||||
passing the ``github`` provider hosts from ``auth.json`` so GitHub
|
||||
Enterprise Server release assets resolve via ``/api/v3``.
|
||||
Delegates to the shared helper in :mod:`specify_cli._github_http`.
|
||||
"""
|
||||
from specify_cli._github_http import resolve_github_release_asset_api_url
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
|
||||
return resolve_github_release_asset_api_url(
|
||||
download_url,
|
||||
self._open_url,
|
||||
timeout=timeout,
|
||||
github_hosts=github_provider_hosts(),
|
||||
download_url, self._open_url, timeout=timeout
|
||||
)
|
||||
|
||||
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
|
||||
|
||||
@@ -22,17 +22,13 @@ ARGUMENT_HINTS: dict[str, str] = {
|
||||
}
|
||||
|
||||
# Per-command frontmatter overrides for skills that should run in a forked
|
||||
# subagent context. See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent
|
||||
#
|
||||
# This is intentionally empty. ``analyze`` was previously forked (added in
|
||||
# #2511) on the assumption that its heavy reads collapse to a short summary,
|
||||
# but in practice ``/speckit-analyze`` returns a 300-500 line report that is
|
||||
# injected back into the main conversation. In long sessions each subsequent
|
||||
# fork inherits that growing context, compounding overhead until the chat
|
||||
# freezes (#3185). Until a command genuinely returns a compact result, no
|
||||
# command opts into ``context: fork``. The injection mechanism below stays in
|
||||
# place so a future command can be added here when that holds true.
|
||||
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {}
|
||||
# subagent context. Read-only analysis commands are good candidates: the
|
||||
# heavy reads (spec/plan/tasks artefacts) collapse to a short summary,
|
||||
# so isolating them keeps the main conversation context clean.
|
||||
# See https://code.claude.com/docs/en/skills#run-skills-in-a-subagent
|
||||
FORK_CONTEXT_COMMANDS: dict[str, dict[str, str]] = {
|
||||
"analyze": {"context": "fork", "agent": "general-purpose"},
|
||||
}
|
||||
|
||||
|
||||
class ClaudeIntegration(SkillsIntegration):
|
||||
|
||||
@@ -1892,19 +1892,10 @@ class PresetCatalog:
|
||||
download_url: str,
|
||||
timeout: int = 60,
|
||||
) -> Optional[str]:
|
||||
"""Resolve a GitHub release asset URL to its REST API asset URL.
|
||||
|
||||
Passes the ``github`` provider hosts from ``auth.json`` so GitHub
|
||||
Enterprise Server release assets resolve via ``/api/v3``.
|
||||
"""
|
||||
"""Resolve a GitHub release asset URL to its REST API asset URL."""
|
||||
from specify_cli._github_http import resolve_github_release_asset_api_url
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
|
||||
return resolve_github_release_asset_api_url(
|
||||
download_url,
|
||||
self._open_url,
|
||||
timeout=timeout,
|
||||
github_hosts=github_provider_hosts(),
|
||||
download_url, self._open_url, timeout=timeout
|
||||
)
|
||||
|
||||
def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None:
|
||||
|
||||
@@ -144,13 +144,10 @@ def preset_add(
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
try:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
from specify_cli._github_http import resolve_github_release_asset_api_url
|
||||
|
||||
_preset_extra_headers = None
|
||||
_resolved_from_url = resolve_github_release_asset_api_url(
|
||||
from_url, _open_url, github_hosts=github_provider_hosts()
|
||||
)
|
||||
_resolved_from_url = resolve_github_release_asset_api_url(from_url, _open_url)
|
||||
if _resolved_from_url:
|
||||
from_url = _resolved_from_url
|
||||
_preset_extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
@@ -45,7 +45,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Goal
|
||||
@@ -229,7 +228,6 @@ After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Operating Principles
|
||||
|
||||
@@ -66,7 +66,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Execution Steps.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Execution Steps
|
||||
@@ -364,5 +363,4 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
@@ -49,7 +49,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
@@ -252,7 +251,6 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
@@ -46,7 +46,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
@@ -148,5 +147,4 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
@@ -49,7 +49,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Goal.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
@@ -267,6 +266,5 @@ After producing the result, check if `.specify/extensions.yml` exists in the pro
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
@@ -45,7 +45,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
@@ -193,7 +192,6 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
@@ -53,7 +53,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
@@ -92,7 +91,6 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
@@ -50,7 +50,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
@@ -254,7 +253,6 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
@@ -54,7 +54,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
@@ -112,7 +111,6 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- **Optional hook** (`optional: true`):
|
||||
```
|
||||
## Extension Hooks
|
||||
|
||||
@@ -46,7 +46,6 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Wait for the result of the hook command before proceeding to the Outline.
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
## Outline
|
||||
@@ -101,5 +100,4 @@ Check if `.specify/extensions.yml` exists in the project root.
|
||||
Executing: `/{command}`
|
||||
EXECUTE_COMMAND: {command}
|
||||
```
|
||||
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
|
||||
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||
|
||||
@@ -298,24 +298,6 @@ class TestCreateFeatureBash:
|
||||
assert data["BRANCH_NAME"] == "001-user-auth"
|
||||
assert data["FEATURE_NUM"] == "001"
|
||||
|
||||
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
|
||||
"""A short word is dropped from the derived branch name unless it appears
|
||||
as an acronym in UPPERCASE in the description (case-sensitive, must match the
|
||||
PowerShell twin)."""
|
||||
project = _setup_project(tmp_path)
|
||||
# lowercase "go" (<3 chars, not an uppercase acronym) is dropped
|
||||
r1 = _run_bash(
|
||||
"create-new-feature-branch.sh", project, "--json", "--dry-run", "Add go support",
|
||||
)
|
||||
assert r1.returncode == 0, r1.stderr
|
||||
assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support"
|
||||
# uppercase "GO" is kept as an acronym
|
||||
r2 = _run_bash(
|
||||
"create-new-feature-branch.sh", project, "--json", "--dry-run", "Use GO now",
|
||||
)
|
||||
assert r2.returncode == 0, r2.stderr
|
||||
assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now"
|
||||
|
||||
def test_creates_branch_timestamp(self, tmp_path: Path):
|
||||
"""Extension create-new-feature-branch.sh creates timestamp branch."""
|
||||
project = _setup_project(tmp_path)
|
||||
@@ -444,21 +426,6 @@ class TestCreateFeaturePowerShell:
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH_NAME"] == "001-user-auth"
|
||||
|
||||
def test_branch_name_short_word_case_sensitivity(self, tmp_path: Path):
|
||||
"""PowerShell must match the bash twin: a short word is dropped unless it
|
||||
appears as an acronym in UPPERCASE (case-sensitive -cmatch, not -match)."""
|
||||
project = _setup_project(tmp_path)
|
||||
r1 = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project, "-Json", "-DryRun", "Add go support",
|
||||
)
|
||||
assert r1.returncode == 0, r1.stderr
|
||||
assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support"
|
||||
r2 = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project, "-Json", "-DryRun", "Use GO now",
|
||||
)
|
||||
assert r2.returncode == 0, r2.stderr
|
||||
assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now"
|
||||
|
||||
def test_dry_run_counts_branches_checked_out_in_worktrees(self, tmp_path: Path):
|
||||
"""Branches checked out in sibling worktrees still reserve their prefix."""
|
||||
project = _setup_project(tmp_path / "project")
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
"""Tests that update-agent-context.sh/.ps1 prefer feature.json over mtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
from tests.extensions.test_extension_agent_context import (
|
||||
BASH,
|
||||
POWERSHELL,
|
||||
_bash_posix_path,
|
||||
_run_bash_agent_context_script,
|
||||
_run_powershell_agent_context_script,
|
||||
)
|
||||
|
||||
|
||||
def _setup_project(root: Path, context_file: str = "CLAUDE.md") -> None:
|
||||
"""Write agent-context extension config as JSON.
|
||||
|
||||
JSON is valid YAML so bash+PyYAML can parse it, and PowerShell's built-in
|
||||
ConvertFrom-Json can parse it without needing powershell-yaml or Python.
|
||||
Written directly as JSON (not via yaml.safe_dump) so the PS ConvertFrom-Json
|
||||
fallback actually works on Windows CI.
|
||||
"""
|
||||
cfg_dir = root / ".specify" / "extensions" / "agent-context"
|
||||
cfg_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cfg_dir / "agent-context-config.yml").write_text(
|
||||
json.dumps({
|
||||
"context_file": context_file,
|
||||
"context_markers": {
|
||||
"start": "<!-- SPECKIT START -->",
|
||||
"end": "<!-- SPECKIT END -->",
|
||||
},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_feature_json(root: Path, feature_directory: str) -> None:
|
||||
specify_dir = root / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _make_plan(root: Path, feature_dir: str, content: str = "# plan\n") -> Path:
|
||||
p = root / feature_dir / "plan.md"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_uses_feature_json_when_plan_exists(tmp_path: Path) -> None:
|
||||
"""feature.json points to the active feature; that plan.md is injected."""
|
||||
_setup_project(tmp_path)
|
||||
_make_plan(tmp_path, "specs/001-active")
|
||||
_write_feature_json(tmp_path, "specs/001-active")
|
||||
|
||||
result = _run_bash_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/001-active/plan.md" in ctx
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None:
|
||||
"""An older spec's plan.md modified more recently must NOT win over feature.json."""
|
||||
_setup_project(tmp_path)
|
||||
active = _make_plan(tmp_path, "specs/001-active")
|
||||
stale = _make_plan(tmp_path, "specs/000-stale")
|
||||
now = time.time()
|
||||
os.utime(active, (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
_write_feature_json(tmp_path, "specs/001-active")
|
||||
|
||||
result = _run_bash_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/001-active/plan.md" in ctx
|
||||
assert "specs/000-stale/plan.md" not in ctx
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_falls_back_to_mtime_when_feature_json_absent(tmp_path: Path) -> None:
|
||||
"""No feature.json → mtime fallback selects the most recently modified plan."""
|
||||
_setup_project(tmp_path)
|
||||
old = _make_plan(tmp_path, "specs/000-old")
|
||||
newer = _make_plan(tmp_path, "specs/001-newer")
|
||||
now = time.time()
|
||||
os.utime(old, (now - 10, now - 10))
|
||||
os.utime(newer, (now, now))
|
||||
|
||||
result = _run_bash_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/001-newer/plan.md" in ctx
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> None:
|
||||
"""feature.json exists but plan.md not yet written → fall back to mtime."""
|
||||
_setup_project(tmp_path)
|
||||
_make_plan(tmp_path, "specs/000-old")
|
||||
_write_feature_json(tmp_path, "specs/001-new")
|
||||
|
||||
result = _run_bash_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/000-old/plan.md" in ctx
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None:
|
||||
"""Absolute feature_directory under PROJECT_ROOT → project-relative path in context."""
|
||||
_setup_project(tmp_path)
|
||||
active = _make_plan(tmp_path, "specs/001-active")
|
||||
stale = _make_plan(tmp_path, "specs/000-stale")
|
||||
now = time.time()
|
||||
os.utime(active, (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
# Write POSIX absolute path — mtime would pick 000-stale without feature.json
|
||||
_write_feature_json(tmp_path, _bash_posix_path(tmp_path / "specs" / "001-active"))
|
||||
|
||||
result = _run_bash_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/001-active/plan.md" in ctx
|
||||
assert "specs/000-stale/plan.md" not in ctx
|
||||
assert _bash_posix_path(tmp_path) not in ctx
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None:
|
||||
"""Absolute feature_directory outside PROJECT_ROOT → absolute path preserved in context."""
|
||||
project = tmp_path / "project"
|
||||
external = tmp_path / "external" / "001-feature"
|
||||
project.mkdir()
|
||||
external.mkdir(parents=True)
|
||||
(external / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
_setup_project(project)
|
||||
_write_feature_json(project, _bash_posix_path(external))
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (project / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert _bash_posix_path(external) + "/plan.md" in ctx
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None:
|
||||
"""PowerShell: absolute feature_directory under project root is normalized to relative path."""
|
||||
_setup_project(tmp_path)
|
||||
active = _make_plan(tmp_path, "specs/001-active")
|
||||
stale = _make_plan(tmp_path, "specs/000-stale")
|
||||
now = time.time()
|
||||
os.utime(active, (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
# Native str() — PowerShell expects Windows-native paths, not MSYS2 /c/... form
|
||||
_write_feature_json(tmp_path, str(tmp_path / "specs" / "001-active"))
|
||||
|
||||
result = _run_powershell_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "at specs/001-active/plan.md" in ctx
|
||||
assert "specs/000-stale/plan.md" not in ctx
|
||||
assert tmp_path.resolve().as_posix() not in ctx
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_ps_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None:
|
||||
"""PowerShell: stale plan touched more recently must not win over feature.json."""
|
||||
_setup_project(tmp_path)
|
||||
active = _make_plan(tmp_path, "specs/001-active")
|
||||
stale = _make_plan(tmp_path, "specs/000-stale")
|
||||
now = time.time()
|
||||
os.utime(active, (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
_write_feature_json(tmp_path, "specs/001-active")
|
||||
|
||||
result = _run_powershell_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/001-active/plan.md" in ctx
|
||||
assert "specs/000-stale/plan.md" not in ctx
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_ps_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None:
|
||||
"""PowerShell: absolute feature_directory outside project root → absolute path preserved."""
|
||||
project = tmp_path / "project"
|
||||
external = tmp_path / "external" / "001-feature"
|
||||
project.mkdir()
|
||||
external.mkdir(parents=True)
|
||||
(external / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
_setup_project(project)
|
||||
_write_feature_json(project, str(external))
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (project / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert external.resolve().as_posix() + "/plan.md" in ctx
|
||||
@@ -539,16 +539,8 @@ class TestClaudeDisableModelInvocation:
|
||||
class TestClaudeForkContext:
|
||||
"""Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS."""
|
||||
|
||||
def test_no_commands_fork_by_default(self):
|
||||
"""FORK_CONTEXT_COMMANDS is empty: no command opts into context: fork.
|
||||
|
||||
``analyze`` was removed (#3185) because its verbose report defeated the
|
||||
purpose of forking and compounded context overhead across repeated runs.
|
||||
"""
|
||||
assert FORK_CONTEXT_COMMANDS == {}
|
||||
|
||||
def test_analyze_skill_does_not_fork(self, tmp_path):
|
||||
"""speckit-analyze must run in the main session, not a forked subagent (#3185)."""
|
||||
def test_analyze_skill_runs_in_forked_subagent(self, tmp_path):
|
||||
"""speckit-analyze must opt into context: fork + agent."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
@@ -557,10 +549,10 @@ class TestClaudeForkContext:
|
||||
content = analyze_skill.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert "context" not in parsed
|
||||
assert "agent" not in parsed
|
||||
assert parsed.get("context") == "fork"
|
||||
assert parsed.get("agent") == "general-purpose"
|
||||
|
||||
def test_no_skills_fork(self, tmp_path):
|
||||
def test_other_skills_do_not_fork(self, tmp_path):
|
||||
"""Skills not in FORK_CONTEXT_COMMANDS must not get context: fork."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
@@ -582,39 +574,60 @@ class TestClaudeForkContext:
|
||||
f"{f.parent.name}: must not have agent frontmatter"
|
||||
)
|
||||
|
||||
def test_post_process_no_fork_for_skills(self):
|
||||
"""With FORK_CONTEXT_COMMANDS empty, post_process must not add context/agent."""
|
||||
def test_fork_flags_inside_frontmatter(self, tmp_path):
|
||||
"""context/agent must appear in the frontmatter, not in the body."""
|
||||
i = get_integration("claude")
|
||||
for name in ("speckit-analyze", "speckit-plan"):
|
||||
content = f'---\nname: "{name}"\ndescription: "x"\n---\n\nBody\n'
|
||||
result = i.post_process_skill_content(content)
|
||||
parsed = yaml.safe_load(result.split("---", 2)[1])
|
||||
assert "context" not in parsed
|
||||
assert "agent" not in parsed
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
|
||||
content = analyze_skill.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3
|
||||
frontmatter = parts[1]
|
||||
body = parts[2]
|
||||
assert "context: fork" in frontmatter
|
||||
assert "agent: general-purpose" in frontmatter
|
||||
assert "context: fork" not in body
|
||||
assert "agent: general-purpose" not in body
|
||||
|
||||
def test_fork_mechanism_injects_when_configured(self, monkeypatch):
|
||||
"""The injection mechanism still works for any command added to
|
||||
FORK_CONTEXT_COMMANDS, even though none ships enabled by default."""
|
||||
import specify_cli.integrations.claude as claude_mod
|
||||
def test_fork_injection_idempotent(self, tmp_path):
|
||||
"""Re-running setup must not duplicate the fork frontmatter keys."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
|
||||
content = analyze_skill.read_text(encoding="utf-8")
|
||||
assert content.count("context: fork") == 1
|
||||
assert content.count("agent: general-purpose") == 1
|
||||
|
||||
monkeypatch.setitem(
|
||||
claude_mod.FORK_CONTEXT_COMMANDS,
|
||||
"analyze",
|
||||
{"context": "fork", "agent": "general-purpose"},
|
||||
)
|
||||
def test_fork_context_injected_via_post_process(self):
|
||||
"""Preset/extension generators call post_process_skill_content directly,
|
||||
bypassing setup(); fork context must be injected there too."""
|
||||
i = get_integration("claude")
|
||||
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
|
||||
result = i.post_process_skill_content(content)
|
||||
parts = result.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
parsed = yaml.safe_load(result.split("---", 2)[1])
|
||||
assert parsed.get("context") == "fork"
|
||||
assert parsed.get("agent") == "general-purpose"
|
||||
# Flags must land in the frontmatter, not the body.
|
||||
assert "context: fork" in parts[1]
|
||||
assert "context: fork" not in parts[2]
|
||||
# Re-running must not duplicate the injected keys.
|
||||
twice = i.post_process_skill_content(result)
|
||||
assert result == twice
|
||||
assert parsed.get("argument-hint") == ARGUMENT_HINTS["analyze"]
|
||||
|
||||
def test_post_process_no_fork_for_other_skills(self):
|
||||
"""Skills not in FORK_CONTEXT_COMMANDS must not gain context/agent."""
|
||||
i = get_integration("claude")
|
||||
content = '---\nname: "speckit-plan"\ndescription: "x"\n---\n\nBody\n'
|
||||
result = i.post_process_skill_content(content)
|
||||
parsed = yaml.safe_load(result.split("---", 2)[1])
|
||||
assert "context" not in parsed
|
||||
assert "agent" not in parsed
|
||||
|
||||
def test_post_process_fork_idempotent(self):
|
||||
"""Re-running post_process must not duplicate fork frontmatter keys."""
|
||||
i = get_integration("claude")
|
||||
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
|
||||
once = i.post_process_skill_content(content)
|
||||
twice = i.post_process_skill_content(once)
|
||||
assert once == twice
|
||||
assert twice.count("context: fork") == 1
|
||||
assert twice.count("agent: general-purpose") == 1
|
||||
|
||||
|
||||
@@ -900,45 +900,3 @@ class TestFetchLatestReleaseTagDelegation:
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Accept") == "application/vnd.github+json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# github_provider_hosts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGithubProviderHosts:
|
||||
"""Tests for github_provider_hosts() — the GHES host allowlist source."""
|
||||
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", entries)
|
||||
|
||||
def test_returns_hosts_from_github_entries(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [
|
||||
AuthConfigEntry(hosts=("ghes.example", "raw.ghes.example"),
|
||||
provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
assert github_provider_hosts() == ("ghes.example", "raw.ghes.example")
|
||||
|
||||
def test_empty_when_no_config(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [])
|
||||
assert github_provider_hosts() == ()
|
||||
|
||||
def test_ignores_non_github_providers(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [
|
||||
AuthConfigEntry(hosts=("dev.azure.com",), provider="azure-devops",
|
||||
auth="basic-pat", token="t"),
|
||||
])
|
||||
assert github_provider_hosts() == ()
|
||||
|
||||
def test_unions_multiple_github_entries(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
||||
AuthConfigEntry(hosts=("github.com",), provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
assert github_provider_hosts() == ("ghes.example", "github.com")
|
||||
|
||||
@@ -16,10 +16,8 @@ import platform
|
||||
import tempfile
|
||||
import shutil
|
||||
import tomllib
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests.conftest import strip_ansi
|
||||
from specify_cli.extensions import (
|
||||
@@ -7282,36 +7280,3 @@ class TestExtensionForceCLI:
|
||||
)
|
||||
assert result2.exit_code == 0, strip_ansi(result2.output)
|
||||
assert "installed" in strip_ansi(result2.output)
|
||||
|
||||
|
||||
def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch):
|
||||
"""End-to-end wiring: auth.json github host → GHES asset resolution."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
from specify_cli.extensions import ExtensionCatalog
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github",
|
||||
auth="bearer", token="t"),
|
||||
])
|
||||
catalog = ExtensionCatalog(tmp_path)
|
||||
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def fake_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "ext.zip",
|
||||
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}]
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
monkeypatch.setattr(catalog, "_open_url", fake_open)
|
||||
|
||||
resolved = catalog._resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip"
|
||||
)
|
||||
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"]
|
||||
|
||||
@@ -188,117 +188,3 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
)
|
||||
assert len(captured_urls) == 1
|
||||
assert "releases/tags/v1%23beta" in captured_urls[0]
|
||||
|
||||
# --- GHES (GitHub Enterprise Server) ---
|
||||
|
||||
def test_resolves_ghes_browser_url_to_api_url(self):
|
||||
"""A GHES browser release URL resolves to the /api/v3 asset URL."""
|
||||
release_json = {
|
||||
"assets": [
|
||||
{"name": "ext.zip",
|
||||
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}
|
||||
]
|
||||
}
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
||||
self._make_open_url_fn(release_json),
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
|
||||
def test_passthrough_for_existing_ghes_api_asset_url(self):
|
||||
"""An already-resolved GHES /api/v3 asset URL is returned as-is."""
|
||||
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
result = resolve_github_release_asset_api_url(
|
||||
url, lambda *a, **kw: None, github_hosts=("ghes.example",)
|
||||
)
|
||||
assert result == url
|
||||
|
||||
def test_returns_none_for_ghes_host_not_in_allowlist(self):
|
||||
"""Unlisted hosts get no GHES treatment and trigger no API call (anti-SSRF)."""
|
||||
called = []
|
||||
|
||||
@contextmanager
|
||||
def recording_open(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b"{}"
|
||||
yield resp
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
||||
recording_open,
|
||||
github_hosts=("other.example",),
|
||||
)
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
|
||||
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
||||
not allowlisted: passthrough issues no API request, and the download
|
||||
helper gates the token independently, so octet-stream resolution must
|
||||
not be withheld."""
|
||||
called = []
|
||||
|
||||
@contextmanager
|
||||
def recording_open(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b"{}"
|
||||
yield resp
|
||||
|
||||
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
result = resolve_github_release_asset_api_url(
|
||||
url, recording_open, github_hosts=("other.example",)
|
||||
)
|
||||
assert result == url
|
||||
assert called == []
|
||||
|
||||
def test_ghes_api_base_preserves_scheme_and_port(self):
|
||||
"""The GHES API base mirrors the URL scheme and keeps a non-standard port."""
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({"assets": []}).encode()
|
||||
yield resp
|
||||
|
||||
resolve_github_release_asset_api_url(
|
||||
"http://localhost:8000/o/r/releases/download/v1/ext.zip",
|
||||
capturing_open,
|
||||
github_hosts=("localhost",),
|
||||
)
|
||||
assert captured == ["http://localhost:8000/api/v3/repos/o/r/releases/tags/v1"]
|
||||
|
||||
def test_ghes_wildcard_does_not_match_bare_host(self):
|
||||
"""A '*.suffix' pattern does not match the bare host (must list it explicitly)."""
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
||||
lambda *a, **kw: None,
|
||||
github_hosts=("*.ghes.example",),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_public_github_url_unaffected_by_github_hosts(self):
|
||||
"""Public github.com still resolves via api.github.com even with github_hosts set."""
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "pack.zip",
|
||||
"url": "https://api.github.com/repos/org/repo/releases/assets/99"}]
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1.0/pack.zip",
|
||||
capturing_open,
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
|
||||
assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
|
||||
|
||||
@@ -17,11 +17,9 @@ import tempfile
|
||||
import shutil
|
||||
import warnings
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -4754,69 +4752,6 @@ class TestPresetAddFromUrlResolution:
|
||||
assert captured_urls[0][0] == "https://api.github.com/repos/org/repo/releases/assets/42"
|
||||
assert captured_urls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch):
|
||||
"""'preset add --from <ghes-release-url>' resolves via GHES /api/v3 endpoint."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
|
||||
manifest_content = yaml.dump({
|
||||
"schema_version": "1.0",
|
||||
"preset": {"id": "my-preset", "name": "My Preset", "version": "1.0.0", "description": "Test preset", "author": "Test", "license": "MIT"},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"templates": [{"type": "template", "name": "t", "file": "templates/t.md", "description": "t"}]},
|
||||
})
|
||||
zip_buf = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buf, "w") as zf:
|
||||
zf.writestr("preset.yml", manifest_content)
|
||||
zip_bytes = zip_buf.getvalue()
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
"assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
|
||||
}).encode())
|
||||
return FakeResponse(zip_bytes)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.get_speckit_version", return_value="1.0.0"), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, [
|
||||
"preset", "add",
|
||||
"--from", "https://ghes.example/org/repo/releases/download/v1.0/preset.zip",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# The tag-lookup call must use the GHES /api/v3 endpoint
|
||||
assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls)
|
||||
# The asset download call must carry Accept: application/octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
||||
assert len(asset_calls) >= 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
class TestWrapStrategy:
|
||||
"""Tests for strategy: wrap preset command substitution."""
|
||||
@@ -6086,36 +6021,3 @@ def _create_pack(temp_dir, valid_pack_data, pack_id, content,
|
||||
(subdir / f"{template_name}.md").write_text(content)
|
||||
|
||||
return pack_dir
|
||||
|
||||
|
||||
def test_preset_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, monkeypatch):
|
||||
"""End-to-end wiring for presets: auth.json github host → GHES asset resolution."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
from specify_cli.presets import PresetCatalog
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github",
|
||||
auth="bearer", token="t"),
|
||||
])
|
||||
catalog = PresetCatalog(tmp_path)
|
||||
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def fake_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "pack.zip",
|
||||
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/9"}]
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
monkeypatch.setattr(catalog, "_open_url", fake_open)
|
||||
|
||||
resolved = catalog._resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v2/pack.zip"
|
||||
)
|
||||
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/9"
|
||||
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v2"]
|
||||
|
||||
@@ -240,17 +240,6 @@ class TestSequentialBranch:
|
||||
assert branch is not None
|
||||
assert re.match(r"^\d{3,}-new-feat$", branch), f"unexpected branch: {branch}"
|
||||
|
||||
def test_branch_name_short_word_case_sensitivity(self, git_repo: Path):
|
||||
"""A short word is dropped from the derived branch name unless it appears
|
||||
as an acronym in UPPERCASE in the description. The PowerShell twin must use
|
||||
case-sensitive -cmatch to produce the same result."""
|
||||
r1 = run_script(git_repo, "--json", "--dry-run", "Add go support")
|
||||
assert r1.returncode == 0, r1.stderr
|
||||
assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support"
|
||||
r2 = run_script(git_repo, "--json", "--dry-run", "Use GO now")
|
||||
assert r2.returncode == 0, r2.stderr
|
||||
assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now"
|
||||
|
||||
def test_sequential_ignores_timestamp_dirs(self, git_repo: Path):
|
||||
"""Sequential numbering skips timestamp dirs when computing next number."""
|
||||
(git_repo / "specs" / "002-first-feat").mkdir(parents=True)
|
||||
@@ -283,25 +272,6 @@ class TestSequentialBranchPowerShell:
|
||||
assert "[long]::TryParse($matches[1], [ref]$num)" in content
|
||||
assert "$num = [int]$matches[1]" not in content
|
||||
|
||||
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
|
||||
def test_branch_name_short_word_case_sensitivity(self, ps_git_repo: Path):
|
||||
"""Core create-new-feature.ps1 must drop a short word unless it appears as
|
||||
an acronym in UPPERCASE (case-sensitive -cmatch), matching the bash twin."""
|
||||
script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1"
|
||||
|
||||
def _run(desc: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["pwsh", "-NoProfile", "-File", str(script), "-Json", "-DryRun", desc],
|
||||
cwd=ps_git_repo, capture_output=True, text=True,
|
||||
)
|
||||
|
||||
r1 = _run("Add go support")
|
||||
assert r1.returncode == 0, r1.stderr
|
||||
assert json.loads(r1.stdout)["BRANCH_NAME"] == "001-support"
|
||||
r2 = _run("Use GO now")
|
||||
assert r2.returncode == 0, r2.stderr
|
||||
assert json.loads(r2.stdout)["BRANCH_NAME"] == "001-use-go-now"
|
||||
|
||||
|
||||
# ── check_feature_branch Tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -5477,137 +5477,6 @@ steps:
|
||||
assert len(asset_calls) >= 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
def test_workflow_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch):
|
||||
"""'workflow add <ghes-release-url>' resolves via GHES /api/v3 endpoint."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
"assets": [{"name": "workflow.yml", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
|
||||
}).encode())
|
||||
return FakeResponse(self.VALID_WORKFLOW_YAML.encode())
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, [
|
||||
"workflow", "add",
|
||||
"https://ghes.example/org/repo/releases/download/v1.0/workflow.yml",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Tag lookup must use the GHES /api/v3 endpoint
|
||||
assert any("ghes.example/api/v3/repos/org/repo/releases/tags/v1.0" in url for url, _ in captured_urls)
|
||||
# Asset download must carry Accept: application/octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
||||
assert len(asset_calls) >= 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
def test_workflow_add_catalog_based_ghes_release_url_resolves_via_api_v3(self, project_dir, monkeypatch):
|
||||
"""'workflow add <id>' with a GHES catalog URL resolves via /api/v3."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
|
||||
captured_urls = []
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data, url=None):
|
||||
self._data = data
|
||||
self._url = url or "https://ghes.example/api/v3/repos/org/repo/releases/assets/55"
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
ghes_wf_yaml = """
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "my-wf"
|
||||
name: "My GHES Workflow"
|
||||
version: "1.0.0"
|
||||
description: "A GHES catalog workflow"
|
||||
steps:
|
||||
- id: step-one
|
||||
type: shell
|
||||
run: "echo hello"
|
||||
"""
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeResponse(json.dumps({
|
||||
"assets": [{"name": "workflow.yml", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/55"}]
|
||||
}).encode())
|
||||
return FakeResponse(ghes_wf_yaml.encode())
|
||||
|
||||
fake_catalog_info = {
|
||||
"id": "my-wf",
|
||||
"name": "My GHES Workflow",
|
||||
"version": "1.0.0",
|
||||
"url": "https://ghes.example/org/repo/releases/download/v2.0/workflow.yml",
|
||||
"_install_allowed": True,
|
||||
}
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
|
||||
patch("specify_cli.workflows.catalog.WorkflowCatalog.get_workflow_info", return_value=fake_catalog_info):
|
||||
result = runner.invoke(app, ["workflow", "add", "my-wf"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Tag lookup must use GHES /api/v3
|
||||
tag_calls = [url for url, _ in captured_urls if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1
|
||||
assert "ghes.example/api/v3/repos/org/repo/releases/tags/v2.0" in tag_calls[0]
|
||||
# Asset download must carry Accept: application/octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured_urls if "releases/assets/" in url]
|
||||
assert len(asset_calls) >= 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
class TestWorkflowRunExitCodes:
|
||||
"""CLI-level tests for the run/resume process exit codes."""
|
||||
|
||||
Reference in New Issue
Block a user