mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 13:12:23 +08:00
Compare commits
6 Commits
v0.8.18
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14eb384381 | ||
|
|
089feca75f | ||
|
|
3617cd9c02 | ||
|
|
50da3a0f77 | ||
|
|
cd8a39f50e | ||
|
|
e53cb2c143 |
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
with:
|
||||
dotnet-version: '8.x'
|
||||
|
||||
|
||||
21
AGENTS.md
21
AGENTS.md
@@ -177,7 +177,24 @@ def _register_builtins() -> None:
|
||||
|
||||
Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate.
|
||||
|
||||
Only add custom setup logic when the agent needs non-standard behavior. Most integrations do not need wrapper scripts or separate context-update dispatch code.
|
||||
The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Path to the coding agent context file managed by this extension
|
||||
context_file: CLAUDE.md
|
||||
|
||||
# Delimiters for the managed Spec Kit section
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run.
|
||||
- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly — both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth.
|
||||
|
||||
Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
|
||||
|
||||
Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts — the `agent-context` extension is fully generic.
|
||||
|
||||
### 5. Test it
|
||||
|
||||
@@ -409,7 +426,7 @@ When an issue exists, include its number immediately after the prefix — this i
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
|
||||
2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated.
|
||||
2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally.
|
||||
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
|
||||
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.
|
||||
5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added.
|
||||
|
||||
57
extensions/agent-context/README.md
Normal file
57
extensions/agent-context/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Coding Agent Context Extension
|
||||
|
||||
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration.
|
||||
|
||||
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
|
||||
|
||||
## Why an extension?
|
||||
|
||||
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users:
|
||||
|
||||
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value.
|
||||
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration flows through the extension's own config file at
|
||||
`.specify/extensions/agent-context/agent-context-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Path to the coding agent context file managed by this extension
|
||||
context_file: CLAUDE.md
|
||||
|
||||
# Delimiters for the managed Spec Kit section
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
|
||||
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
|
||||
|
||||
## Requirements
|
||||
|
||||
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
|
||||
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
|
||||
```bash
|
||||
pip install pyyaml
|
||||
# or target the specific interpreter Spec Kit uses:
|
||||
/path/to/speckit-python -m pip install pyyaml
|
||||
```
|
||||
|
||||
## Disable
|
||||
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
```
|
||||
|
||||
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
|
||||
15
extensions/agent-context/agent-context-config.yml
Normal file
15
extensions/agent-context/agent-context-config.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# Coding Agent Context Extension Configuration
|
||||
# These values are populated automatically by `specify init` and
|
||||
# `specify integration use` / `specify integration install`.
|
||||
|
||||
# Path (relative to the project root) to the coding agent context file
|
||||
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
|
||||
# .github/copilot-instructions.md). Set automatically from the active
|
||||
# integration and regenerated during `specify init` or integration switches.
|
||||
context_file: ""
|
||||
|
||||
# Delimiters for the managed Spec Kit section.
|
||||
# Edit these to use custom markers.
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description: "Refresh the managed Spec Kit section in the coding agent context file"
|
||||
---
|
||||
|
||||
# Update Coding Agent Context
|
||||
|
||||
Refresh the managed Spec Kit section inside the active coding agent's context/instruction file (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`).
|
||||
|
||||
## Behavior
|
||||
|
||||
The script reads the agent-context extension config at
|
||||
`.specify/extensions/agent-context/agent-context-config.yml` to discover:
|
||||
|
||||
- `context_file` — the path of the coding agent context file to manage.
|
||||
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
|
||||
|
||||
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
|
||||
|
||||
If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.
|
||||
|
||||
## Execution
|
||||
|
||||
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
|
||||
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
|
||||
|
||||
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.
|
||||
34
extensions/agent-context/extension.yml
Normal file
34
extensions/agent-context/extension.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
extension:
|
||||
id: agent-context
|
||||
name: "Coding Agent Context"
|
||||
version: "1.0.0"
|
||||
description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers"
|
||||
author: spec-kit-core
|
||||
repository: https://github.com/github/spec-kit
|
||||
license: MIT
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.2.0"
|
||||
|
||||
provides:
|
||||
commands:
|
||||
- name: speckit.agent-context.update
|
||||
file: commands/speckit.agent-context.update.md
|
||||
description: "Refresh the managed Spec Kit section in the coding agent context file"
|
||||
|
||||
hooks:
|
||||
after_specify:
|
||||
command: speckit.agent-context.update
|
||||
optional: true
|
||||
description: "Refresh agent context after specification"
|
||||
after_plan:
|
||||
command: speckit.agent-context.update
|
||||
optional: true
|
||||
description: "Refresh agent context after planning"
|
||||
|
||||
tags:
|
||||
- "agent"
|
||||
- "context"
|
||||
- "core"
|
||||
200
extensions/agent-context/scripts/bash/update-agent-context.sh
Executable file
200
extensions/agent-context/scripts/bash/update-agent-context.sh
Executable file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-agent-context.sh
|
||||
#
|
||||
# Refresh the managed Spec Kit section in the coding agent's context file
|
||||
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
|
||||
#
|
||||
# Reads `context_file` and `context_markers.{start,end}` from the
|
||||
# agent-context extension config:
|
||||
# .specify/extensions/agent-context/agent-context-config.yml
|
||||
#
|
||||
# Usage: update-agent-context.sh [plan_path]
|
||||
#
|
||||
# When `plan_path` is omitted, the script picks the most recently modified
|
||||
# `specs/*/plan.md` if any exist, otherwise emits the section without a
|
||||
# concrete plan path.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(pwd)"
|
||||
EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml"
|
||||
DEFAULT_START="<!-- SPECKIT START -->"
|
||||
DEFAULT_END="<!-- SPECKIT END -->"
|
||||
|
||||
if [[ ! -f "$EXT_CONFIG" ]]; then
|
||||
echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Locate a suitable Python interpreter (python3, then python).
|
||||
_python=""
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
_python="python3"
|
||||
elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then
|
||||
_python="python"
|
||||
fi
|
||||
|
||||
if [[ -z "$_python" ]]; then
|
||||
echo "agent-context: Python 3 not found on PATH; skipping update." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse extension config once; emit three newline-separated fields:
|
||||
# context_file, context_markers.start, context_markers.end
|
||||
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY'
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print(
|
||||
"agent-context: PyYAML is required to parse extension config but is not available "
|
||||
"in the current Python environment.\n"
|
||||
" To resolve: pip install pyyaml (or install it into the environment used by python3).\n"
|
||||
" Context file will not be updated until PyYAML is importable.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
try:
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
def get_str(obj, *keys):
|
||||
node = obj
|
||||
for k in keys:
|
||||
if isinstance(node, dict) and k in node:
|
||||
node = node[k]
|
||||
else:
|
||||
return ""
|
||||
return node if isinstance(node, str) else ""
|
||||
print(get_str(data, "context_file"))
|
||||
print(get_str(data, "context_markers", "start"))
|
||||
print(get_str(data, "context_markers", "end"))
|
||||
PY
|
||||
)"; then
|
||||
echo "agent-context: skipping update (see above for details)." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
_opts_lines=()
|
||||
while IFS= read -r _line || [[ -n "$_line" ]]; do
|
||||
_opts_lines+=("$_line")
|
||||
done < <(printf '%s\n' "$_raw_opts")
|
||||
if (( ${#_opts_lines[@]} < 3 )); then
|
||||
echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
|
||||
exit 0
|
||||
fi
|
||||
CONTEXT_FILE="${_opts_lines[0]}"
|
||||
MARKER_START="${_opts_lines[1]}"
|
||||
MARKER_END="${_opts_lines[2]}"
|
||||
|
||||
if [[ -z "$CONTEXT_FILE" ]]; then
|
||||
echo "agent-context: context_file not set in extension config; nothing to do." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Reject absolute paths, backslash separators, and '..' path segments in context_file
|
||||
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
|
||||
echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CONTEXT_FILE" == *\\* ]]; then
|
||||
echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2
|
||||
exit 1
|
||||
fi
|
||||
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
|
||||
for _seg in "${_cf_parts[@]}"; do
|
||||
if [[ "$_seg" == ".." ]]; then
|
||||
echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
unset _cf_parts _seg
|
||||
|
||||
[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START"
|
||||
[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END"
|
||||
|
||||
PLAN_PATH="${1:-}"
|
||||
if [[ -z "$PLAN_PATH" ]]; then
|
||||
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
|
||||
# Use find + sort by modification time to avoid ls/head fragility with
|
||||
# spaces in paths or SIGPIPE from pipefail.
|
||||
_plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
|
||||
import sys, os
|
||||
from pathlib import Path
|
||||
specs = Path(sys.argv[1]) / "specs"
|
||||
plans = sorted(
|
||||
specs.glob("*/plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
print(plans[0] if plans else "")
|
||||
PY
|
||||
)"
|
||||
if [[ -n "$_plan_abs" ]]; then
|
||||
PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
|
||||
fi
|
||||
fi
|
||||
|
||||
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
|
||||
mkdir -p "$(dirname "$CTX_PATH")"
|
||||
|
||||
# Build the managed section
|
||||
TMP_SECTION="$(mktemp)"
|
||||
trap 'rm -f "$TMP_SECTION"' EXIT
|
||||
{
|
||||
echo "$MARKER_START"
|
||||
echo "For additional context about technologies to be used, project structure,"
|
||||
echo "shell commands, and other important information, read the current plan"
|
||||
if [[ -n "$PLAN_PATH" ]]; then
|
||||
echo "at $PLAN_PATH"
|
||||
fi
|
||||
echo "$MARKER_END"
|
||||
} > "$TMP_SECTION"
|
||||
|
||||
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
|
||||
import sys, os
|
||||
ctx_path, start, end, section_path = sys.argv[1:5]
|
||||
with open(section_path, "r", encoding="utf-8") as fh:
|
||||
section = fh.read().rstrip("\n") + "\n"
|
||||
|
||||
if os.path.exists(ctx_path):
|
||||
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
|
||||
content = fh.read()
|
||||
s = content.find(start)
|
||||
e = content.find(end, s if s != -1 else 0)
|
||||
if s != -1 and e != -1 and e > s:
|
||||
end_of_marker = e + len(end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = content[:s] + section + content[end_of_marker:]
|
||||
elif s != -1:
|
||||
new_content = content[:s] + section
|
||||
elif e != -1:
|
||||
end_of_marker = e + len(end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = section + content[end_of_marker:]
|
||||
else:
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
new_content = (content + "\n" + section) if content else section
|
||||
else:
|
||||
new_content = section
|
||||
|
||||
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
with open(ctx_path, "wb") as fh:
|
||||
fh.write(new_content.encode("utf-8"))
|
||||
PY
|
||||
|
||||
echo "agent-context: updated $CONTEXT_FILE"
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# update-agent-context.ps1
|
||||
#
|
||||
# Refresh the managed Spec Kit section in the coding agent's context file
|
||||
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
|
||||
#
|
||||
# Reads `context_file` and `context_markers.{start,end}` from the
|
||||
# agent-context extension config:
|
||||
# .specify/extensions/agent-context/agent-context-config.yml
|
||||
#
|
||||
# Usage: update-agent-context.ps1 [plan_path]
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[string]$PlanPath
|
||||
)
|
||||
|
||||
function Get-ConfigValue {
|
||||
param(
|
||||
[AllowNull()][object]$Object,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return $null
|
||||
}
|
||||
if ($Object -is [System.Collections.IDictionary]) {
|
||||
return $Object[$Key]
|
||||
}
|
||||
$prop = $Object.PSObject.Properties[$Key]
|
||||
if ($prop) {
|
||||
return $prop.Value
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-ConfigObject {
|
||||
param(
|
||||
[AllowNull()][object]$Object
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return $false
|
||||
}
|
||||
if ($Object -is [System.Collections.IDictionary]) {
|
||||
return $true
|
||||
}
|
||||
if ($Object -is [System.Management.Automation.PSCustomObject]) {
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$DefaultStart = '<!-- SPECKIT START -->'
|
||||
$DefaultEnd = '<!-- SPECKIT END -->'
|
||||
$ProjectRoot = (Get-Location).Path
|
||||
$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ExtConfig)) {
|
||||
Write-Warning "agent-context: $ExtConfig not found; nothing to do."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$Options = $null
|
||||
if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
|
||||
try {
|
||||
$Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop
|
||||
} catch {
|
||||
# fall through to Python fallback
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $Options) {
|
||||
# ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
|
||||
$pythonCmd = $null
|
||||
foreach ($candidate in @('python3', 'python')) {
|
||||
if (Get-Command $candidate -ErrorAction SilentlyContinue) {
|
||||
# Verify it is Python 3
|
||||
$verOut = & $candidate --version 2>&1
|
||||
if ($verOut -match 'Python 3') {
|
||||
$pythonCmd = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($pythonCmd) {
|
||||
try {
|
||||
$jsonOut = & $pythonCmd -c @'
|
||||
import json
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print(
|
||||
"agent-context: PyYAML is required to parse extension config; cannot update context.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
print(json.dumps(data))
|
||||
'@ $ExtConfig
|
||||
if ($LASTEXITCODE -eq 0 -and $jsonOut) {
|
||||
$Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
} catch {
|
||||
$Options = $null
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Options) {
|
||||
Write-Warning "agent-context: unable to parse $ExtConfig; skipping update."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-ConfigObject -Object $Options)) {
|
||||
Write-Warning "agent-context: $ExtConfig must contain a YAML mapping; skipping update."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file'
|
||||
if (-not $ContextFile) {
|
||||
Write-Warning 'agent-context: context_file not set in extension config; nothing to do.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Reject absolute paths and '..' path segments in context_file
|
||||
if ([System.IO.Path]::IsPathRooted($ContextFile)) {
|
||||
Write-Warning "agent-context: context_file must be a project-relative path; got '$ContextFile'."
|
||||
exit 1
|
||||
}
|
||||
$cfSegments = $ContextFile -split '[/\\]'
|
||||
if ($cfSegments -contains '..') {
|
||||
Write-Warning "agent-context: context_file must not contain '..' path segments; got '$ContextFile'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$MarkerStart = $DefaultStart
|
||||
$MarkerEnd = $DefaultEnd
|
||||
$cm = Get-ConfigValue -Object $Options -Key 'context_markers'
|
||||
if ($cm) {
|
||||
$cmStart = Get-ConfigValue -Object $cm -Key 'start'
|
||||
if ($cmStart -is [string] -and $cmStart) {
|
||||
$MarkerStart = $cmStart
|
||||
}
|
||||
$cmEnd = Get-ConfigValue -Object $cm -Key 'end'
|
||||
if ($cmEnd -is [string] -and $cmEnd) {
|
||||
$MarkerEnd = $cmEnd
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $PlanPath) {
|
||||
# Discover plan.md exactly one level deep (specs/<feature>/plan.md),
|
||||
# matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
|
||||
# $ErrorActionPreference = 'Stop' don't abort the script.
|
||||
try {
|
||||
$specsDir = Join-Path $ProjectRoot 'specs'
|
||||
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_ } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($candidate) {
|
||||
$PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/')
|
||||
}
|
||||
} catch {
|
||||
# Non-fatal: continue without a plan path.
|
||||
}
|
||||
}
|
||||
|
||||
$CtxPath = Join-Path $ProjectRoot $ContextFile
|
||||
$CtxDir = Split-Path -Parent $CtxPath
|
||||
if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) {
|
||||
New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$lines = @($MarkerStart,
|
||||
'For additional context about technologies to be used, project structure,',
|
||||
'shell commands, and other important information, read the current plan')
|
||||
if ($PlanPath) {
|
||||
$lines += "at $PlanPath"
|
||||
}
|
||||
$lines += $MarkerEnd
|
||||
$Section = ($lines -join "`n") + "`n"
|
||||
|
||||
if (Test-Path -LiteralPath $CtxPath) {
|
||||
$rawBytes = [System.IO.File]::ReadAllBytes($CtxPath)
|
||||
# Strip UTF-8 BOM if present
|
||||
if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) {
|
||||
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3)
|
||||
} else {
|
||||
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
|
||||
}
|
||||
|
||||
$s = $content.IndexOf($MarkerStart)
|
||||
$e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) }
|
||||
|
||||
if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) {
|
||||
$endOfMarker = $e + $MarkerEnd.Length
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
|
||||
$newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker)
|
||||
} elseif ($s -ge 0) {
|
||||
$newContent = $content.Substring(0, $s) + $Section
|
||||
} elseif ($e -ge 0) {
|
||||
$endOfMarker = $e + $MarkerEnd.Length
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
|
||||
$newContent = $Section + $content.Substring($endOfMarker)
|
||||
} else {
|
||||
if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
|
||||
if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section }
|
||||
}
|
||||
} else {
|
||||
$newContent = $Section
|
||||
}
|
||||
|
||||
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
|
||||
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
|
||||
|
||||
Write-Host "agent-context: updated $ContextFile"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-28T00:00:00Z",
|
||||
"updated_at": "2026-05-30T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -2218,8 +2218,8 @@
|
||||
"id": "reqnroll-bdd",
|
||||
"description": "Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit.",
|
||||
"author": "LoogaCY Studio",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
|
||||
"homepage": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
|
||||
"documentation": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd#readme",
|
||||
@@ -2249,7 +2249,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-13T00:00:00Z",
|
||||
"updated_at": "2026-05-13T00:00:00Z"
|
||||
"updated_at": "2026-05-30T00:00:00Z"
|
||||
},
|
||||
"retro": {
|
||||
"name": "Retro Extension",
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
|
||||
"extensions": {
|
||||
"agent-context": {
|
||||
"name": "Coding Agent Context",
|
||||
"id": "agent-context",
|
||||
"version": "1.0.0",
|
||||
"description": "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"bundled": true,
|
||||
"tags": [
|
||||
"agent",
|
||||
"context",
|
||||
"core"
|
||||
]
|
||||
},
|
||||
"git": {
|
||||
"name": "Git Branching Workflow",
|
||||
"id": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.18"
|
||||
version = "0.8.19.dev0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -40,6 +40,7 @@ packages = ["src/specify_cli"]
|
||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||
# Bundled extensions (installable via `specify extension add <name>`)
|
||||
"extensions/git" = "specify_cli/core_pack/extensions/git"
|
||||
"extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context"
|
||||
# Bundled workflows (auto-installed during `specify init`)
|
||||
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
|
||||
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
|
||||
|
||||
@@ -304,6 +304,72 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-context extension config helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AGENT_CTX_EXT_CONFIG = (
|
||||
Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
)
|
||||
|
||||
|
||||
def _load_agent_context_config(project_root: Path) -> dict[str, Any]:
|
||||
"""Load the agent-context extension config, returning defaults on failure."""
|
||||
from .integrations.base import IntegrationBase
|
||||
|
||||
defaults: dict[str, Any] = {
|
||||
"context_file": "",
|
||||
"context_markers": {
|
||||
"start": IntegrationBase.CONTEXT_MARKER_START,
|
||||
"end": IntegrationBase.CONTEXT_MARKER_END,
|
||||
},
|
||||
}
|
||||
path = project_root / _AGENT_CTX_EXT_CONFIG
|
||||
if not path.exists():
|
||||
return defaults
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, yaml.YAMLError):
|
||||
return defaults
|
||||
if not isinstance(raw, dict):
|
||||
return defaults
|
||||
return raw
|
||||
|
||||
|
||||
def _save_agent_context_config(
|
||||
project_root: Path, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Persist *config* to the agent-context extension config file."""
|
||||
path = project_root / _AGENT_CTX_EXT_CONFIG
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False), encoding="utf-8")
|
||||
|
||||
|
||||
def _update_agent_context_config_file(
|
||||
project_root: Path,
|
||||
context_file: str | None,
|
||||
*,
|
||||
preserve_markers: bool = True,
|
||||
) -> None:
|
||||
"""Update the agent-context extension config with *context_file*.
|
||||
|
||||
When *preserve_markers* is True (default), any existing
|
||||
``context_markers`` values are kept unchanged so user customisations
|
||||
survive integration changes and reinit. When False, the default
|
||||
markers are written unconditionally.
|
||||
"""
|
||||
from .integrations.base import IntegrationBase
|
||||
|
||||
cfg = _load_agent_context_config(project_root)
|
||||
cfg["context_file"] = context_file or ""
|
||||
if not preserve_markers or not isinstance(cfg.get("context_markers"), dict):
|
||||
cfg["context_markers"] = {
|
||||
"start": IntegrationBase.CONTEXT_MARKER_START,
|
||||
"end": IntegrationBase.CONTEXT_MARKER_END,
|
||||
}
|
||||
_save_agent_context_config(project_root, cfg)
|
||||
|
||||
|
||||
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
||||
"""Resolve the agent-specific skills directory.
|
||||
|
||||
@@ -649,13 +715,31 @@ def _refresh_init_options_speckit_version(project_root: Path) -> None:
|
||||
|
||||
|
||||
def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None:
|
||||
"""Clear active integration keys from init-options.json when they match."""
|
||||
"""Clear active integration keys from init-options.json when they match.
|
||||
|
||||
Also clears ``context_file`` from the agent-context extension config so
|
||||
no stale path is left behind when the integration is uninstalled.
|
||||
"""
|
||||
opts = load_init_options(project_root)
|
||||
has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts)
|
||||
# Remove legacy fields that older versions may have written.
|
||||
opts.pop("context_file", None)
|
||||
opts.pop("context_markers", None)
|
||||
|
||||
if opts.get("integration") == integration_key or opts.get("ai") == integration_key:
|
||||
opts.pop("integration", None)
|
||||
opts.pop("ai", None)
|
||||
opts.pop("ai_skills", None)
|
||||
opts.pop("context_file", None)
|
||||
save_init_options(project_root, opts)
|
||||
# Clear context_file in the extension config if it already exists.
|
||||
# Avoid creating the config (and parent dirs) in projects where the
|
||||
# agent-context extension was never installed.
|
||||
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
|
||||
if ext_cfg_path.exists():
|
||||
_update_agent_context_config_file(
|
||||
project_root, "", preserve_markers=True
|
||||
)
|
||||
elif has_legacy_context_keys:
|
||||
save_init_options(project_root, opts)
|
||||
|
||||
|
||||
@@ -1100,12 +1184,23 @@ def _update_init_options_for_integration(
|
||||
integration: Any,
|
||||
script_type: str | None = None,
|
||||
) -> None:
|
||||
"""Update ``init-options.json`` to reflect *integration* as the active one."""
|
||||
"""Update init-options.json and the agent-context extension config to
|
||||
reflect *integration* as the active one.
|
||||
|
||||
``context_file`` and ``context_markers`` are stored in the agent-context
|
||||
extension config (``.specify/extensions/agent-context/agent-context-config.yml``),
|
||||
not in ``init-options.json``. Existing user-customised markers are
|
||||
always preserved when the config already exists; invalid marker values
|
||||
are silently ignored at runtime by ``_resolve_context_markers()`` which
|
||||
falls back to the class-level defaults.
|
||||
"""
|
||||
from .integrations.base import SkillsIntegration
|
||||
opts = load_init_options(project_root)
|
||||
opts["integration"] = integration.key
|
||||
opts["ai"] = integration.key
|
||||
opts["context_file"] = integration.context_file
|
||||
# Remove legacy fields if they were written by an older version.
|
||||
opts.pop("context_file", None)
|
||||
opts.pop("context_markers", None)
|
||||
opts["speckit_version"] = get_speckit_version()
|
||||
if script_type:
|
||||
opts["script"] = script_type
|
||||
@@ -1113,6 +1208,25 @@ def _update_init_options_for_integration(
|
||||
opts["ai_skills"] = True
|
||||
else:
|
||||
opts.pop("ai_skills", None)
|
||||
|
||||
# Update the agent-context extension config BEFORE init-options.json
|
||||
# so a failure here doesn't leave init-options partially updated.
|
||||
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
|
||||
if ext_cfg_path.exists():
|
||||
_update_agent_context_config_file(
|
||||
project_root,
|
||||
integration.context_file,
|
||||
preserve_markers=True,
|
||||
)
|
||||
elif integration.context_file:
|
||||
# Extension config doesn't exist yet (extension not installed).
|
||||
# Write defaults so scripts have something to read.
|
||||
_update_agent_context_config_file(
|
||||
project_root,
|
||||
integration.context_file,
|
||||
preserve_markers=False,
|
||||
)
|
||||
|
||||
save_init_options(project_root, opts)
|
||||
|
||||
|
||||
@@ -2968,6 +3082,43 @@ def extension_add(
|
||||
manager = ExtensionManager(project_root)
|
||||
speckit_version = get_speckit_version()
|
||||
|
||||
# Prompt for URL-based installs BEFORE the spinner so the user can
|
||||
# actually see and respond to the confirmation (the Rich status
|
||||
# spinner overwrites the typer.confirm prompt line, making it appear
|
||||
# as though the command is hung).
|
||||
# Guard with ``not dev`` so that --dev + --from does not show a
|
||||
# confusing confirmation for a URL that will be ignored.
|
||||
if from_url and not dev:
|
||||
from urllib.parse import urlparse
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
parsed = urlparse(from_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
console.print("[red]Error:[/red] URL must use HTTPS for security.")
|
||||
console.print("HTTP is only allowed for localhost URLs.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
safe_url = _escape_markup(from_url)
|
||||
|
||||
# Warn about untrusted sources — default-deny confirmation
|
||||
console.print()
|
||||
console.print(Panel(
|
||||
f"[bold]You are installing an extension from an external URL that is not\n"
|
||||
f"listed in any of your configured extension catalogs.[/bold]\n\n"
|
||||
f"URL: {safe_url}\n\n"
|
||||
f"Only install extensions from sources you trust.",
|
||||
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
))
|
||||
console.print()
|
||||
confirm = typer.confirm("Continue with installation?", default=False)
|
||||
if not confirm:
|
||||
console.print("Cancelled")
|
||||
raise typer.Exit(0)
|
||||
|
||||
try:
|
||||
with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
|
||||
if dev:
|
||||
@@ -2990,37 +3141,9 @@ def extension_add(
|
||||
|
||||
elif from_url:
|
||||
# Install from URL (ZIP file)
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Validate URL
|
||||
parsed = urlparse(from_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
console.print("[red]Error:[/red] URL must use HTTPS for security.")
|
||||
console.print("HTTP is only allowed for localhost URLs.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Warn about untrusted sources — default-deny confirmation
|
||||
console.print()
|
||||
console.print(Panel(
|
||||
f"[bold]You are installing an extension from an external URL that is not\n"
|
||||
f"listed in any of your configured extension catalogs.[/bold]\n\n"
|
||||
f"URL: {from_url}\n\n"
|
||||
f"Only install extensions from sources you trust.",
|
||||
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
))
|
||||
console.print()
|
||||
confirm = typer.confirm("Continue with installation?", default=False)
|
||||
if not confirm:
|
||||
console.print("Cancelled")
|
||||
raise typer.Exit(0)
|
||||
|
||||
console.print(f"Downloading from {from_url}...")
|
||||
console.print(f"Downloading from {safe_url}...")
|
||||
|
||||
# Download ZIP to temp location
|
||||
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
@@ -3037,7 +3160,7 @@ def extension_add(
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
|
||||
console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}")
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
# Clean up downloaded ZIP
|
||||
|
||||
@@ -374,8 +374,15 @@ class CommandRegistrar:
|
||||
|
||||
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
|
||||
|
||||
# Resolve __CONTEXT_FILE__ from init-options
|
||||
context_file = init_opts.get("context_file") or ""
|
||||
# Resolve __CONTEXT_FILE__ from the agent-context extension config.
|
||||
# Fall back to init-options.json for projects that haven't migrated.
|
||||
# Local import: _load_agent_context_config lives in __init__.py which
|
||||
# imports agents.py, so a top-level import would be circular.
|
||||
from . import _load_agent_context_config
|
||||
ac_cfg = _load_agent_context_config(project_root)
|
||||
context_file = ac_cfg.get("context_file") or ""
|
||||
if not context_file:
|
||||
context_file = init_opts.get("context_file") or ""
|
||||
body = body.replace("__CONTEXT_FILE__", context_file)
|
||||
|
||||
return CommandRegistrar.rewrite_project_relative_paths(body)
|
||||
|
||||
@@ -153,6 +153,7 @@ def register(app: typer.Typer) -> None:
|
||||
_install_shared_infra_or_exit,
|
||||
_parse_integration_options,
|
||||
_print_cli_warning,
|
||||
_update_agent_context_config_file,
|
||||
_write_integration_json,
|
||||
ensure_executable_scripts,
|
||||
save_init_options,
|
||||
@@ -394,6 +395,7 @@ def register(app: typer.Typer) -> None:
|
||||
("constitution", "Constitution setup"),
|
||||
("git", "Install git extension"),
|
||||
("workflow", "Install bundled workflow"),
|
||||
("agent-context", "Install agent-context extension"),
|
||||
("final", "Finalize"),
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
@@ -535,13 +537,10 @@ def register(app: typer.Typer) -> None:
|
||||
sanitized_wf = str(wf_err).replace('\n', ' ').strip()
|
||||
tracker.error("workflow", f"install failed: {sanitized_wf[:120]}")
|
||||
|
||||
ensure_executable_scripts(project_path, tracker=tracker)
|
||||
|
||||
init_opts = {
|
||||
"ai": selected_ai,
|
||||
"integration": resolved_integration.key,
|
||||
"branch_numbering": branch_numbering or "sequential",
|
||||
"context_file": resolved_integration.context_file,
|
||||
"here": here,
|
||||
"script": selected_script,
|
||||
"speckit_version": get_speckit_version(),
|
||||
@@ -551,6 +550,47 @@ def register(app: typer.Typer) -> None:
|
||||
init_opts["ai_skills"] = True
|
||||
save_init_options(project_path, init_opts)
|
||||
|
||||
# --- agent-context extension (bundled, auto-installed) ---
|
||||
# Installed after init-options.json is written so that skill
|
||||
# registration can read ai_skills + integration key.
|
||||
try:
|
||||
from ..extensions import ExtensionManager as _ExtMgr
|
||||
bundled_ac = _locate_bundled_extension("agent-context")
|
||||
if bundled_ac:
|
||||
ac_mgr = _ExtMgr(project_path)
|
||||
if ac_mgr.registry.is_installed("agent-context"):
|
||||
tracker.complete("agent-context", "already installed")
|
||||
else:
|
||||
ac_mgr.install_from_directory(
|
||||
bundled_ac, get_speckit_version()
|
||||
)
|
||||
tracker.complete("agent-context", "extension installed")
|
||||
else:
|
||||
from ..extensions import REINSTALL_COMMAND as _ac_reinstall
|
||||
tracker.error(
|
||||
"agent-context",
|
||||
f"bundled extension not found — installation may be "
|
||||
f"incomplete. Run: {_ac_reinstall}",
|
||||
)
|
||||
except Exception as ac_err:
|
||||
sanitized_ac = str(ac_err).replace('\n', ' ').strip()
|
||||
tracker.error(
|
||||
"agent-context",
|
||||
f"extension install failed: {sanitized_ac[:120]}",
|
||||
)
|
||||
|
||||
# Write context_file to the agent-context extension config
|
||||
# AFTER the extension install (which copies the template config
|
||||
# with an empty context_file).
|
||||
if resolved_integration.context_file:
|
||||
_update_agent_context_config_file(
|
||||
project_path,
|
||||
resolved_integration.context_file,
|
||||
preserve_markers=True,
|
||||
)
|
||||
|
||||
ensure_executable_scripts(project_path, tracker=tracker)
|
||||
|
||||
if preset:
|
||||
try:
|
||||
from ..presets import PresetManager, PresetCatalog, PresetError
|
||||
|
||||
@@ -13,6 +13,7 @@ Provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
@@ -549,6 +550,91 @@ class IntegrationBase(ABC):
|
||||
lines.append(f"at {plan_path}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _agent_context_extension_enabled(project_root: Path) -> bool:
|
||||
"""Return whether the bundled ``agent-context`` extension is enabled.
|
||||
|
||||
The extension is the single source of truth for managing coding
|
||||
agent context/instruction files (e.g. ``CLAUDE.md``,
|
||||
``.github/copilot-instructions.md``).
|
||||
|
||||
Returns ``True`` (enabled) when:
|
||||
- the extension registry does not exist (legacy project, backwards
|
||||
compatibility), or
|
||||
- the registry has no ``agent-context`` entry (older project layout
|
||||
predating the extension), or
|
||||
- the entry is present and not explicitly disabled.
|
||||
|
||||
Returns ``False`` only when an entry exists with ``enabled: false``.
|
||||
"""
|
||||
registry_path = (
|
||||
project_root / ".specify" / "extensions" / ".registry"
|
||||
)
|
||||
if not registry_path.exists():
|
||||
return True
|
||||
try:
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
return True
|
||||
if not isinstance(data, dict):
|
||||
return True
|
||||
extensions = data.get("extensions")
|
||||
if not isinstance(extensions, dict):
|
||||
return True
|
||||
entry = extensions.get("agent-context")
|
||||
if not isinstance(entry, dict):
|
||||
return True
|
||||
return entry.get("enabled", True) is not False
|
||||
|
||||
def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]:
|
||||
"""Return the (start, end) context markers to use for *project_root*.
|
||||
|
||||
Reads ``context_markers.start`` / ``context_markers.end`` from the
|
||||
agent-context extension config
|
||||
(``.specify/extensions/agent-context/agent-context-config.yml``)
|
||||
when present. Falls back to the class-level constants
|
||||
``CONTEXT_MARKER_START`` / ``CONTEXT_MARKER_END`` when the file is
|
||||
missing, the section is absent, or the values are not non-empty
|
||||
strings.
|
||||
"""
|
||||
from .._console import console # local import to avoid cycles
|
||||
|
||||
start = self.CONTEXT_MARKER_START
|
||||
end = self.CONTEXT_MARKER_END
|
||||
config_path = (
|
||||
project_root
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
try:
|
||||
raw = config_path.read_text(encoding="utf-8")
|
||||
cfg = yaml.safe_load(raw)
|
||||
except (OSError, UnicodeError, ValueError, yaml.YAMLError):
|
||||
return start, end
|
||||
markers = cfg.get("context_markers") if isinstance(cfg, dict) else None
|
||||
if isinstance(markers, dict):
|
||||
cm_start = markers.get("start")
|
||||
cm_end = markers.get("end")
|
||||
s_valid = isinstance(cm_start, str) and cm_start
|
||||
e_valid = isinstance(cm_end, str) and cm_end
|
||||
if not s_valid and cm_start is not None:
|
||||
console.print(
|
||||
f"[yellow]agent-context: ignoring invalid context_markers.start "
|
||||
f"({cm_start!r}), using default[/yellow]"
|
||||
)
|
||||
if not e_valid and cm_end is not None:
|
||||
console.print(
|
||||
f"[yellow]agent-context: ignoring invalid context_markers.end "
|
||||
f"({cm_end!r}), using default[/yellow]"
|
||||
)
|
||||
if s_valid:
|
||||
start = cm_start # type: ignore[assignment]
|
||||
if e_valid:
|
||||
end = cm_end # type: ignore[assignment]
|
||||
return start, end
|
||||
|
||||
def upsert_context_section(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -557,34 +643,54 @@ class IntegrationBase(ABC):
|
||||
"""Create or update the managed section in the agent context file.
|
||||
|
||||
If the context file does not exist it is created with just the
|
||||
managed section. If it exists, the content between
|
||||
``<!-- SPECKIT START -->`` and ``<!-- SPECKIT END -->`` markers
|
||||
is replaced (or appended when no markers are found).
|
||||
managed section. If it exists, the content between the configured
|
||||
start/end markers (default ``<!-- SPECKIT START -->`` /
|
||||
``<!-- SPECKIT END -->``) is replaced, or appended when no markers
|
||||
are found. Markers are read from the agent-context extension config
|
||||
(``.specify/extensions/agent-context/agent-context-config.yml``)
|
||||
when present, falling back to the class-level constants.
|
||||
|
||||
Returns the path to the context file, or ``None`` when
|
||||
``context_file`` is not set.
|
||||
``context_file`` is not set or the ``agent-context`` extension is
|
||||
disabled.
|
||||
"""
|
||||
if not self.context_file:
|
||||
return None
|
||||
|
||||
if not self._agent_context_extension_enabled(project_root):
|
||||
return None
|
||||
|
||||
from .._console import console # local import to avoid cycles
|
||||
|
||||
console.print(
|
||||
"[yellow]Deprecation:[/yellow] Inline agent-context updates during "
|
||||
"integration setup will be disabled in v0.12.0. Context file "
|
||||
"management has moved to the bundled [bold]agent-context[/bold] "
|
||||
"extension. Run [cyan]specify extension disable agent-context[/cyan] "
|
||||
"to opt out early.",
|
||||
highlight=False,
|
||||
)
|
||||
|
||||
marker_start, marker_end = self._resolve_context_markers(project_root)
|
||||
|
||||
ctx_path = project_root / self.context_file
|
||||
section = (
|
||||
f"{self.CONTEXT_MARKER_START}\n"
|
||||
f"{marker_start}\n"
|
||||
f"{self._build_context_section(plan_path)}\n"
|
||||
f"{self.CONTEXT_MARKER_END}\n"
|
||||
f"{marker_end}\n"
|
||||
)
|
||||
|
||||
if ctx_path.exists():
|
||||
content = ctx_path.read_text(encoding="utf-8-sig")
|
||||
start_idx = content.find(self.CONTEXT_MARKER_START)
|
||||
start_idx = content.find(marker_start)
|
||||
end_idx = content.find(
|
||||
self.CONTEXT_MARKER_END,
|
||||
marker_end,
|
||||
start_idx if start_idx != -1 else 0,
|
||||
)
|
||||
|
||||
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
|
||||
# Replace existing section (include the end marker + newline)
|
||||
end_of_marker = end_idx + len(self.CONTEXT_MARKER_END)
|
||||
end_of_marker = end_idx + len(marker_end)
|
||||
# Consume trailing line ending (CRLF or LF)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
@@ -596,7 +702,7 @@ class IntegrationBase(ABC):
|
||||
new_content = content[:start_idx] + section
|
||||
elif end_idx != -1:
|
||||
# Corrupted: end marker without start — replace BOF through end marker
|
||||
end_of_marker = end_idx + len(self.CONTEXT_MARKER_END)
|
||||
end_of_marker = end_idx + len(marker_end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
@@ -630,20 +736,27 @@ class IntegrationBase(ABC):
|
||||
"""Remove the managed section from the agent context file.
|
||||
|
||||
Returns ``True`` if the section was found and removed. If the
|
||||
file becomes empty (or whitespace-only) after removal it is
|
||||
deleted.
|
||||
file becomes empty (or whitespace-only) after removal it is deleted.
|
||||
Markers are read from the agent-context extension config
|
||||
(``.specify/extensions/agent-context/agent-context-config.yml``)
|
||||
when present, falling back to the class-level constants.
|
||||
"""
|
||||
if not self.context_file:
|
||||
return False
|
||||
|
||||
if not self._agent_context_extension_enabled(project_root):
|
||||
return False
|
||||
|
||||
ctx_path = project_root / self.context_file
|
||||
if not ctx_path.exists():
|
||||
return False
|
||||
|
||||
marker_start, marker_end = self._resolve_context_markers(project_root)
|
||||
|
||||
content = ctx_path.read_text(encoding="utf-8-sig")
|
||||
start_idx = content.find(self.CONTEXT_MARKER_START)
|
||||
start_idx = content.find(marker_start)
|
||||
end_idx = content.find(
|
||||
self.CONTEXT_MARKER_END,
|
||||
marker_end,
|
||||
start_idx if start_idx != -1 else 0,
|
||||
)
|
||||
|
||||
@@ -654,7 +767,7 @@ class IntegrationBase(ABC):
|
||||
return False
|
||||
|
||||
removal_start = start_idx
|
||||
removal_end = end_idx + len(self.CONTEXT_MARKER_END)
|
||||
removal_end = end_idx + len(marker_end)
|
||||
|
||||
# Consume trailing line ending (CRLF or LF)
|
||||
if removal_end < len(content) and content[removal_end] == "\r":
|
||||
|
||||
@@ -74,7 +74,9 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
2. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
@@ -106,13 +108,13 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
4. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
5. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
@@ -123,7 +125,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
6. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
@@ -241,9 +243,9 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
6. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
7. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
|
||||
@@ -66,7 +66,9 @@ Execution steps:
|
||||
- If JSON parsing fails, abort and instruct user to re-run `__SPECKIT_COMMAND_SPECIFY__` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
2. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
@@ -122,7 +124,7 @@ Execution steps:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 5 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
@@ -133,7 +135,7 @@ Execution steps:
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
5. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
@@ -169,7 +171,7 @@ Execution steps:
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
6. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
@@ -187,7 +189,7 @@ Execution steps:
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
7. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
@@ -195,9 +197,9 @@ Execution steps:
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
8. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. **Re-validate Spec Quality Checklist** (if it exists):
|
||||
9. **Re-validate Spec Quality Checklist** (if it exists):
|
||||
- Check if `FEATURE_DIR/checklists/requirements.md` exists.
|
||||
- If it does NOT exist, skip this step silently.
|
||||
- If it exists:
|
||||
|
||||
@@ -109,7 +109,9 @@ Given that feature description, do this:
|
||||
|
||||
4. Load `templates/spec-template.md` to understand required sections.
|
||||
|
||||
5. Follow this execution flow:
|
||||
5. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
6. Follow this execution flow:
|
||||
1. Parse user description from arguments
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
|
||||
@@ -63,6 +63,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
|
||||
@@ -51,6 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
## Outline
|
||||
|
||||
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
|
||||
455
tests/extensions/test_extension_agent_context.py
Normal file
455
tests/extensions/test_extension_agent_context.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""Tests for the bundled ``agent-context`` extension and related plumbing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli import (
|
||||
_load_agent_context_config,
|
||||
_save_agent_context_config,
|
||||
load_init_options,
|
||||
save_init_options,
|
||||
)
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context"
|
||||
|
||||
|
||||
def _write_ext_config(project_root: Path, **overrides: object) -> None:
|
||||
"""Write a minimal agent-context extension config."""
|
||||
cfg: dict = {
|
||||
"context_file": overrides.get("context_file", ""),
|
||||
"context_markers": overrides.get(
|
||||
"context_markers",
|
||||
{
|
||||
"start": IntegrationBase.CONTEXT_MARKER_START,
|
||||
"end": IntegrationBase.CONTEXT_MARKER_END,
|
||||
},
|
||||
),
|
||||
}
|
||||
_save_agent_context_config(project_root, cfg)
|
||||
|
||||
|
||||
# ── Bundled extension layout ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtensionLayout:
|
||||
"""The bundled agent-context extension ships a complete package."""
|
||||
|
||||
def test_extension_yml_exists(self):
|
||||
assert (EXT_DIR / "extension.yml").is_file()
|
||||
|
||||
def test_extension_yml_has_required_fields(self):
|
||||
manifest = yaml.safe_load((EXT_DIR / "extension.yml").read_text())
|
||||
assert manifest["extension"]["id"] == "agent-context"
|
||||
assert manifest["extension"]["name"] == "Coding Agent Context"
|
||||
assert manifest["extension"]["author"] == "spec-kit-core"
|
||||
# Provides at least the manual update command
|
||||
commands = {c["name"] for c in manifest["provides"]["commands"]}
|
||||
assert "speckit.agent-context.update" in commands
|
||||
|
||||
def test_readme_exists(self):
|
||||
readme = EXT_DIR / "README.md"
|
||||
assert readme.is_file()
|
||||
text = readme.read_text(encoding="utf-8")
|
||||
assert "Coding Agent Context Extension" in text
|
||||
|
||||
def test_config_template_exists(self):
|
||||
cfg = EXT_DIR / "agent-context-config.yml"
|
||||
assert cfg.is_file()
|
||||
parsed = yaml.safe_load(cfg.read_text(encoding="utf-8"))
|
||||
assert "context_file" in parsed
|
||||
assert "context_markers" in parsed
|
||||
|
||||
def test_command_file_exists(self):
|
||||
cmd = EXT_DIR / "commands" / "speckit.agent-context.update.md"
|
||||
assert cmd.is_file()
|
||||
assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8")
|
||||
|
||||
def test_bundled_scripts_exist(self):
|
||||
assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file()
|
||||
assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file()
|
||||
|
||||
def test_bash_script_reads_extension_config(self):
|
||||
text = (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# The script must consult the extension config, not init-options.json
|
||||
assert "agent-context-config.yml" in text
|
||||
assert "context_file" in text
|
||||
assert "context_markers" in text
|
||||
|
||||
|
||||
# ── Catalog registration ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCatalogEntry:
|
||||
def test_catalog_lists_agent_context_as_bundled(self):
|
||||
catalog = json.loads(
|
||||
(PROJECT_ROOT / "extensions" / "catalog.json").read_text(encoding="utf-8")
|
||||
)
|
||||
entry = catalog["extensions"]["agent-context"]
|
||||
assert entry["bundled"] is True
|
||||
assert entry["id"] == "agent-context"
|
||||
assert entry["author"] == "spec-kit-core"
|
||||
|
||||
|
||||
# ── Marker resolution from extension config ──────────────────────────────────
|
||||
|
||||
|
||||
class _CtxIntegration(ClaudeIntegration):
|
||||
"""Use Claude as a concrete integration with a context_file."""
|
||||
|
||||
|
||||
class TestContextMarkerResolution:
|
||||
def test_defaults_when_ext_config_missing(self, tmp_path):
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_defaults_when_markers_field_missing(self, tmp_path):
|
||||
"""Config file exists with context_file but no context_markers key."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text("context_file: CLAUDE.md\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_custom_markers_respected(self, tmp_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_markers={"start": "<!-- BEGIN -->", "end": "<!-- END -->"},
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == "<!-- BEGIN -->"
|
||||
assert end == "<!-- END -->"
|
||||
|
||||
def test_partial_override_falls_back_for_missing_side(self, tmp_path):
|
||||
_write_ext_config(tmp_path, context_markers={"start": "<!-- ONLY START -->"})
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == "<!-- ONLY START -->"
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_invalid_markers_fall_back(self, tmp_path):
|
||||
_write_ext_config(tmp_path, context_markers={"start": 42, "end": ""})
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
|
||||
# ── upsert_context_section / remove_context_section honor markers ───────────
|
||||
|
||||
|
||||
class TestUpsertWithCustomMarkers:
|
||||
def _setup(self, tmp_path: Path, markers: dict | None = None) -> _CtxIntegration:
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
**({"context_markers": markers} if markers is not None else {}),
|
||||
)
|
||||
return _CtxIntegration()
|
||||
|
||||
def test_upsert_uses_default_markers(self, tmp_path):
|
||||
i = self._setup(tmp_path)
|
||||
result = i.upsert_context_section(tmp_path)
|
||||
assert result is not None
|
||||
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert IntegrationBase.CONTEXT_MARKER_START in text
|
||||
assert IntegrationBase.CONTEXT_MARKER_END in text
|
||||
|
||||
def test_upsert_uses_custom_markers(self, tmp_path):
|
||||
i = self._setup(
|
||||
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
|
||||
)
|
||||
i.upsert_context_section(tmp_path)
|
||||
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "<!-- BEGIN -->" in text
|
||||
assert "<!-- END -->" in text
|
||||
# Defaults must not appear
|
||||
assert IntegrationBase.CONTEXT_MARKER_START not in text
|
||||
assert IntegrationBase.CONTEXT_MARKER_END not in text
|
||||
|
||||
def test_upsert_replaces_existing_custom_section(self, tmp_path):
|
||||
i = self._setup(
|
||||
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
|
||||
)
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
ctx.write_text(
|
||||
"# header\n\n<!-- BEGIN -->\nold body\n<!-- END -->\n\nfooter\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
i.upsert_context_section(tmp_path, plan_path="specs/001-foo/plan.md")
|
||||
text = ctx.read_text(encoding="utf-8")
|
||||
assert "old body" not in text
|
||||
assert "specs/001-foo/plan.md" in text
|
||||
assert text.startswith("# header\n")
|
||||
assert "footer" in text
|
||||
|
||||
def test_remove_uses_custom_markers(self, tmp_path):
|
||||
i = self._setup(
|
||||
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
|
||||
)
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
ctx.write_text(
|
||||
"preamble\n\n<!-- BEGIN -->\nbody\n<!-- END -->\nepilogue\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
removed = i.remove_context_section(tmp_path)
|
||||
assert removed is True
|
||||
remaining = ctx.read_text(encoding="utf-8")
|
||||
assert "<!-- BEGIN -->" not in remaining
|
||||
assert "<!-- END -->" not in remaining
|
||||
assert "body" not in remaining
|
||||
assert "preamble" in remaining
|
||||
assert "epilogue" in remaining
|
||||
|
||||
def test_remove_with_default_markers_unchanged_when_custom_in_file(self, tmp_path):
|
||||
# Extension config absent → default markers used. File contains only
|
||||
# custom markers — nothing should be removed.
|
||||
i = _CtxIntegration()
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
original = "x\n<!-- BEGIN -->\nbody\n<!-- END -->\n"
|
||||
ctx.write_text(original, encoding="utf-8")
|
||||
assert i.remove_context_section(tmp_path) is False
|
||||
assert ctx.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
# ── Extension disabled gates setup/teardown ──────────────────────────────────
|
||||
|
||||
|
||||
def _write_registry(project_root: Path, *, enabled: bool) -> None:
|
||||
registry = project_root / ".specify" / "extensions" / ".registry"
|
||||
registry.parent.mkdir(parents=True, exist_ok=True)
|
||||
registry.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"agent-context": {
|
||||
"version": "1.0.0",
|
||||
"enabled": enabled,
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class TestExtensionEnabledGate:
|
||||
def test_enabled_helper_default_when_no_registry(self, tmp_path):
|
||||
assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True
|
||||
|
||||
def test_enabled_helper_when_entry_present(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=True)
|
||||
assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True
|
||||
|
||||
def test_disabled_helper_when_entry_disabled(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
assert IntegrationBase._agent_context_extension_enabled(tmp_path) is False
|
||||
|
||||
def test_upsert_skipped_when_disabled(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _CtxIntegration()
|
||||
result = i.upsert_context_section(tmp_path)
|
||||
assert result is None
|
||||
assert not (tmp_path / "CLAUDE.md").exists()
|
||||
|
||||
def test_remove_skipped_when_disabled(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _CtxIntegration()
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
original = (
|
||||
f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
|
||||
f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n"
|
||||
)
|
||||
ctx.write_text(original, encoding="utf-8")
|
||||
assert i.remove_context_section(tmp_path) is False
|
||||
# File must be unchanged when extension is disabled
|
||||
assert ctx.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
# ── Extension config writers ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtensionConfigWriters:
|
||||
def test_clear_init_options_clears_ext_config_context_file(self, tmp_path):
|
||||
from specify_cli import _clear_init_options_for_integration
|
||||
|
||||
save_init_options(
|
||||
tmp_path,
|
||||
{"integration": "claude", "ai": "claude"},
|
||||
)
|
||||
_write_ext_config(tmp_path, context_file="CLAUDE.md")
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg.get("context_file") == ""
|
||||
|
||||
def test_clear_init_options_creates_ext_config_when_missing(self, tmp_path):
|
||||
from specify_cli import _clear_init_options_for_integration
|
||||
|
||||
save_init_options(
|
||||
tmp_path,
|
||||
{"integration": "claude", "ai": "claude"},
|
||||
)
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg.get("context_file") == ""
|
||||
|
||||
def test_clear_init_options_removes_legacy_context_keys_even_when_not_active(
|
||||
self, tmp_path
|
||||
):
|
||||
from specify_cli import _clear_init_options_for_integration
|
||||
|
||||
save_init_options(
|
||||
tmp_path,
|
||||
{
|
||||
"integration": "copilot",
|
||||
"ai": "copilot",
|
||||
"context_file": "CLAUDE.md",
|
||||
"context_markers": {"start": "<!-- X -->", "end": "<!-- Y -->"},
|
||||
},
|
||||
)
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
opts = load_init_options(tmp_path)
|
||||
assert opts["integration"] == "copilot"
|
||||
assert opts["ai"] == "copilot"
|
||||
assert "context_file" not in opts
|
||||
assert "context_markers" not in opts
|
||||
|
||||
def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path):
|
||||
from specify_cli import _update_init_options_for_integration
|
||||
|
||||
# Pre-create the extension config so _update_init_options_for_integration
|
||||
# updates it (rather than skipping it when ext config doesn't exist yet).
|
||||
_write_ext_config(tmp_path, context_file="")
|
||||
i = _CtxIntegration()
|
||||
_update_init_options_for_integration(tmp_path, i, script_type="sh")
|
||||
# init-options.json must NOT have context_file or context_markers
|
||||
opts = load_init_options(tmp_path)
|
||||
assert "context_file" not in opts
|
||||
assert "context_markers" not in opts
|
||||
# Extension config must have them
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_file"] == i.context_file
|
||||
assert "context_markers" in cfg
|
||||
|
||||
def test_update_init_options_preserves_custom_markers(self, tmp_path):
|
||||
from specify_cli import _update_init_options_for_integration
|
||||
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="",
|
||||
context_markers={"start": "<!-- B -->", "end": "<!-- E -->"},
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
_update_init_options_for_integration(tmp_path, i)
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_markers"] == {"start": "<!-- B -->", "end": "<!-- E -->"}
|
||||
|
||||
def test_reinit_preserves_custom_markers(self, tmp_path):
|
||||
"""specify init (reinit) must not overwrite user-customised markers."""
|
||||
from specify_cli import _update_agent_context_config_file
|
||||
|
||||
# Simulate existing project with custom markers
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_markers={"start": "<!-- CUSTOM -->", "end": "<!-- /CUSTOM -->"},
|
||||
)
|
||||
# Re-running init updates context_file but must preserve markers
|
||||
_update_agent_context_config_file(
|
||||
tmp_path, "CLAUDE.md", preserve_markers=True
|
||||
)
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_markers"] == {
|
||||
"start": "<!-- CUSTOM -->",
|
||||
"end": "<!-- /CUSTOM -->",
|
||||
}
|
||||
|
||||
|
||||
# ── Deprecation warning on upsert ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeprecationWarning:
|
||||
def test_upsert_emits_deprecation_warning(self, tmp_path, capsys):
|
||||
"""upsert_context_section must emit a deprecation notice on stdout."""
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
i = _CtxIntegration()
|
||||
_write_ext_config(tmp_path, context_file="CLAUDE.md")
|
||||
i.upsert_context_section(tmp_path)
|
||||
captured = capsys.readouterr()
|
||||
plain = strip_ansi(captured.out)
|
||||
assert "Deprecation" in plain
|
||||
assert "v0.12.0" in plain
|
||||
assert "agent-context" in plain
|
||||
|
||||
def test_upsert_no_warning_when_disabled(self, tmp_path, capsys):
|
||||
"""No deprecation warning when agent-context extension is disabled."""
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _CtxIntegration()
|
||||
i.upsert_context_section(tmp_path)
|
||||
captured = capsys.readouterr()
|
||||
assert "Deprecation" not in captured.out
|
||||
|
||||
|
||||
# ── Corrupt / invalid extension config ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestCorruptExtensionConfig:
|
||||
def test_marker_resolution_with_corrupt_yaml(self, tmp_path):
|
||||
"""Corrupt YAML in agent-context-config.yml falls back to defaults."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(": invalid: yaml: {{{\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path):
|
||||
"""upsert_context_section still works when config YAML is corrupt."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text("not valid yaml: {{{\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
result = i.upsert_context_section(tmp_path)
|
||||
assert result is not None
|
||||
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert IntegrationBase.CONTEXT_MARKER_START in text
|
||||
assert IntegrationBase.CONTEXT_MARKER_END in text
|
||||
|
||||
def test_marker_resolution_with_non_dict_yaml(self, tmp_path):
|
||||
"""Config file containing a scalar (not a dict) falls back to defaults."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text("just a string\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
@@ -87,7 +87,14 @@ class TestInitIntegrationFlag:
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts["integration"] == "copilot"
|
||||
assert opts["context_file"] == ".github/copilot-instructions.md"
|
||||
# context_file lives in the agent-context extension config, not init-options.json
|
||||
assert "context_file" not in opts
|
||||
|
||||
import yaml as _yaml
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
assert ext_cfg_path.exists(), "agent-context extension config must be created on init"
|
||||
ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8"))
|
||||
assert ext_cfg["context_file"] == ".github/copilot-instructions.md"
|
||||
|
||||
assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
|
||||
|
||||
|
||||
@@ -226,8 +226,8 @@ class MarkdownIntegrationTests:
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -243,15 +243,17 @@ class MarkdownIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
@@ -291,6 +293,16 @@ class MarkdownIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -357,8 +357,8 @@ class SkillsIntegrationTests:
|
||||
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -374,10 +374,11 @@ class SkillsIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- IntegrationOption ------------------------------------------------
|
||||
@@ -402,9 +403,11 @@ class SkillsIntegrationTests:
|
||||
skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
|
||||
|
||||
files = []
|
||||
# Skill files
|
||||
# Skill files (core commands)
|
||||
for cmd in self._SKILL_COMMANDS:
|
||||
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
|
||||
# Extension-installed skill (agent-context)
|
||||
files.append(f"{skills_prefix}/speckit-agent-context-update/SKILL.md")
|
||||
# Integration metadata
|
||||
files += [
|
||||
".specify/init-options.json",
|
||||
@@ -443,6 +446,15 @@ class SkillsIntegrationTests:
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
]
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -457,8 +457,8 @@ class TomlIntegrationTests:
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -474,15 +474,17 @@ class TomlIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
@@ -543,6 +545,16 @@ class TomlIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -336,8 +336,8 @@ class YamlIntegrationTests:
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -353,15 +353,17 @@ class YamlIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
@@ -422,6 +424,16 @@ class YamlIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -178,6 +178,7 @@ class TestCopilotIntegration:
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
".github/agents/speckit.checklist.agent.md",
|
||||
".github/agents/speckit.clarify.agent.md",
|
||||
@@ -187,6 +188,7 @@ class TestCopilotIntegration:
|
||||
".github/agents/speckit.specify.agent.md",
|
||||
".github/agents/speckit.tasks.agent.md",
|
||||
".github/agents/speckit.taskstoissues.agent.md",
|
||||
".github/prompts/speckit.agent-context.update.prompt.md",
|
||||
".github/prompts/speckit.analyze.prompt.md",
|
||||
".github/prompts/speckit.checklist.prompt.md",
|
||||
".github/prompts/speckit.clarify.prompt.md",
|
||||
@@ -198,6 +200,14 @@ class TestCopilotIntegration:
|
||||
".github/prompts/speckit.taskstoissues.prompt.md",
|
||||
".vscode/settings.json",
|
||||
".github/copilot-instructions.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/integration.json",
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
@@ -238,6 +248,7 @@ class TestCopilotIntegration:
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
".github/agents/speckit.checklist.agent.md",
|
||||
".github/agents/speckit.clarify.agent.md",
|
||||
@@ -247,6 +258,7 @@ class TestCopilotIntegration:
|
||||
".github/agents/speckit.specify.agent.md",
|
||||
".github/agents/speckit.tasks.agent.md",
|
||||
".github/agents/speckit.taskstoissues.agent.md",
|
||||
".github/prompts/speckit.agent-context.update.prompt.md",
|
||||
".github/prompts/speckit.analyze.prompt.md",
|
||||
".github/prompts/speckit.checklist.prompt.md",
|
||||
".github/prompts/speckit.clarify.prompt.md",
|
||||
@@ -258,6 +270,14 @@ class TestCopilotIntegration:
|
||||
".github/prompts/speckit.taskstoissues.prompt.md",
|
||||
".vscode/settings.json",
|
||||
".github/copilot-instructions.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/integration.json",
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
@@ -624,10 +644,20 @@ class TestCopilotSkillsMode:
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
expected = sorted([
|
||||
# Skill files
|
||||
# Skill files (core + extension-installed agent-context command)
|
||||
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
||||
".github/skills/speckit-agent-context-update/SKILL.md",
|
||||
# Context file
|
||||
".github/copilot-instructions.md",
|
||||
# Bundled agent-context extension
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
# Integration metadata
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
|
||||
@@ -195,6 +195,39 @@ class TestGenericIntegration:
|
||||
content = implement_file.read_text(encoding="utf-8")
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command_stem",
|
||||
[
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"implement",
|
||||
"plan",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
],
|
||||
)
|
||||
def test_command_loads_constitution_context(self, tmp_path, command_stem):
|
||||
"""Every command except constitution must reference constitution.md."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
cmd_file = tmp_path / ".custom" / "cmds" / f"speckit.{command_stem}.md"
|
||||
assert cmd_file.exists(), f"Command file missing: {cmd_file.name}"
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
assert "constitution.md" in content, (
|
||||
f"speckit.{command_stem}.md must reference constitution.md"
|
||||
)
|
||||
|
||||
def test_constitution_command_exists(self, tmp_path):
|
||||
"""The constitution command itself must exist but is not required to load itself."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
cmd_file = tmp_path / ".custom" / "cmds" / "speckit.constitution.md"
|
||||
assert cmd_file.exists()
|
||||
|
||||
# -- CLI --------------------------------------------------------------
|
||||
|
||||
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
|
||||
@@ -211,8 +244,8 @@ class TestGenericIntegration:
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the generic integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the generic integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -229,8 +262,9 @@ class TestGenericIntegration:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
assert opts.get("context_file") == "AGENTS.md"
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
assert ext_cfg.get("context_file") == "AGENTS.md"
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script sh."""
|
||||
@@ -265,6 +299,14 @@ class TestGenericIntegration:
|
||||
".myagent/commands/speckit.specify.md",
|
||||
".myagent/commands/speckit.tasks.md",
|
||||
".myagent/commands/speckit.taskstoissues.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
@@ -321,6 +363,14 @@ class TestGenericIntegration:
|
||||
".myagent/commands/speckit.specify.md",
|
||||
".myagent/commands/speckit.tasks.md",
|
||||
".myagent/commands/speckit.taskstoissues.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
|
||||
@@ -241,10 +241,15 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
# Ensure no .hermes/skills/speckit-*/SKILL.md in project dir
|
||||
hermes_skill_files = [f for f in actual if f.startswith(".hermes/skills/speckit-")]
|
||||
# Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
hermes_skill_files = [
|
||||
f for f in actual
|
||||
if f.startswith(".hermes/skills/speckit-")
|
||||
and "agent-context" not in f
|
||||
]
|
||||
assert hermes_skill_files == [], (
|
||||
f"Expected no local SKILL.md files, found: {hermes_skill_files}"
|
||||
f"Expected no local core SKILL.md files, found: {hermes_skill_files}"
|
||||
)
|
||||
# Ensure the marker exists (empty dir won't appear in file listing)
|
||||
assert (project / ".hermes" / "skills").is_dir()
|
||||
@@ -274,9 +279,15 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
hermes_skill_files = [f for f in actual if f.startswith(".hermes/skills/speckit-")]
|
||||
# Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
hermes_skill_files = [
|
||||
f for f in actual
|
||||
if f.startswith(".hermes/skills/speckit-")
|
||||
and "agent-context" not in f
|
||||
]
|
||||
assert hermes_skill_files == [], (
|
||||
f"Expected no local SKILL.md files, found: {hermes_skill_files}"
|
||||
f"Expected no local core SKILL.md files, found: {hermes_skill_files}"
|
||||
)
|
||||
assert (project / ".hermes" / "skills").is_dir()
|
||||
|
||||
@@ -342,6 +353,10 @@ class TestHermesAutoPromote:
|
||||
assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
# Local marker should exist
|
||||
assert (target / ".hermes" / "skills").is_dir()
|
||||
# No SKILL.md files in project-local dir
|
||||
local_skills = list((target / ".hermes" / "skills").iterdir())
|
||||
# No core SKILL.md files in project-local dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
local_skills = [
|
||||
d for d in (target / ".hermes" / "skills").iterdir()
|
||||
if "agent-context" not in d.name
|
||||
]
|
||||
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
|
||||
|
||||
@@ -255,7 +255,7 @@ class TestIntegrationInstall:
|
||||
assert updated["speckit_version"] == "0.8.11"
|
||||
assert updated["integration"] == "claude"
|
||||
assert updated["ai"] == "claude"
|
||||
assert updated["context_file"] == "CLAUDE.md"
|
||||
assert "context_file" not in updated
|
||||
|
||||
def test_install_additional_preserves_shared_manifest(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -1250,7 +1250,7 @@ class TestIntegrationUpgrade:
|
||||
assert updated["speckit_version"] == "0.8.11"
|
||||
assert updated["integration"] == "gemini"
|
||||
assert updated["ai"] == "gemini"
|
||||
assert updated["context_file"] == "GEMINI.md"
|
||||
assert "context_file" not in updated
|
||||
|
||||
def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -1376,11 +1376,16 @@ class TestIntegrationUpgrade:
|
||||
new_commands = sorted(canonical.glob("speckit.*.md"))
|
||||
assert len(new_commands) > 0, "Commands should exist in .opencode/commands/"
|
||||
|
||||
# Stale files removed from legacy dir
|
||||
remaining = list(legacy.glob("speckit.*.md"))
|
||||
assert len(remaining) == 0, (
|
||||
f"Legacy .opencode/command/ should have no speckit files after upgrade, "
|
||||
f"found: {[f.name for f in remaining]}"
|
||||
# Stale files removed from legacy dir (extension-installed commands
|
||||
# like agent-context.update may still appear — only check the original
|
||||
# core command stems that should have been migrated).
|
||||
core_remaining = [
|
||||
f for f in legacy.glob("speckit.*.md")
|
||||
if "agent-context" not in f.name
|
||||
]
|
||||
assert len(core_remaining) == 0, (
|
||||
f"Legacy .opencode/command/ should have no core speckit files after upgrade, "
|
||||
f"found: {[f.name for f in core_remaining]}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3807,6 +3807,67 @@ class TestExtensionAddCLI:
|
||||
assert "bundled with spec-kit" in result.output
|
||||
assert "reinstall" in result.output.lower()
|
||||
|
||||
def test_add_from_url_prompts_before_spinner(self, tmp_path):
|
||||
"""Confirm prompt for --from <url> must fire before the console.status spinner.
|
||||
|
||||
Regression test for #2783: typer.confirm() inside console.status()
|
||||
was overwritten by the Rich spinner, making the command appear hung.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch, MagicMock
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
call_order: list[str] = []
|
||||
|
||||
original_status = MagicMock()
|
||||
|
||||
def record_status(*args, **kwargs):
|
||||
call_order.append("spinner")
|
||||
return original_status
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.console.status", side_effect=record_status), \
|
||||
patch("typer.confirm", side_effect=lambda *a, **kw: (call_order.append("confirm"), False)[-1]):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert "confirm" in call_order, "confirm prompt was never called"
|
||||
# The confirm must fire BEFORE the spinner is entered
|
||||
if "spinner" in call_order:
|
||||
assert call_order.index("confirm") < call_order.index("spinner"), \
|
||||
f"confirm must precede spinner, got: {call_order}"
|
||||
assert result.exit_code == 0 # user declined → clean exit
|
||||
|
||||
def test_add_from_url_cancel_exits_cleanly(self, tmp_path):
|
||||
"""Declining the --from <url> confirmation should exit with code 0."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=False):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Cancelled" in result.output
|
||||
|
||||
|
||||
class TestDownloadExtensionBundled:
|
||||
"""Tests for download_extension handling of bundled extensions."""
|
||||
|
||||
Reference in New Issue
Block a user