mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 23:59:55 +08:00
Compare commits
1 Commits
add-ears-e
...
v0.12.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12efa87fc0 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,36 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.12.7] - 2026-07-07
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(bundler): bundle update uninstalls components dropped by new version (#3353)
|
||||
- fix(workflows): route run/resume errors to stderr under --json (#3352)
|
||||
- fix(workflows): fan-in validate() rejects non-mapping output (#3349)
|
||||
- fix(workflows): shell step validate() rejects non-string run (#3348)
|
||||
- fix(integrations): agy honors SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (#3347)
|
||||
- Add Orchestration Task Context Management extension to community catalog (#3372)
|
||||
- Update DocGuard — CDD Enforcement extension to v0.30.0 (#3371)
|
||||
- Update Ripple extension to v1.1.0 (#3370)
|
||||
- feat(integrations): generalize post-processing to all format types (#3311)
|
||||
- chore: release 0.12.6, begin 0.12.7.dev0 development (#3393)
|
||||
|
||||
## [0.12.6] - 2026-07-07
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(bundler): validate catalog URLs in `catalog add` (HTTPS-only, require host) (#3367)
|
||||
- Update Ralph Loop extension to v1.2.1 (#3365)
|
||||
- fix extension-local script path rewriting (#3364)
|
||||
- Add Charter extension to community catalog (#3363)
|
||||
- feat(scripts): add Python check-prerequisites PoC (#3302)
|
||||
- test: reduce registry manifest test repetition (#3146)
|
||||
- fix(integrations): hermes honors SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS (#3346)
|
||||
- fix(extensions): coerce non-mapping YAML config roots to {} in ConfigManager (#3345)
|
||||
- fix(yaml): pin goose recipe prompt block-scalar indentation (#3343)
|
||||
- chore: release 0.12.5, begin 0.12.6.dev0 development (#3381)
|
||||
|
||||
## [0.12.5] - 2026-07-06
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -42,7 +42,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Bugfix Workflow | Structured bugfix workflow — capture bugs, trace to spec artifacts, and patch specs surgically | `process` | Read+Write | [spec-kit-bugfix](https://github.com/Quratulain-bilal/spec-kit-bugfix) |
|
||||
| Canon | Adds canon-driven (baseline-driven) workflows: spec-first, code-first, spec-drift. Requires Canon Core preset installation. | `process` | Read+Write | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon/tree/master/extension) |
|
||||
| Catalog CI | Automated validation for spec-kit community catalog entries — structure, URLs, diffs, and linting | `process` | Read-only | [spec-kit-catalog-ci](https://github.com/Quratulain-bilal/spec-kit-catalog-ci) |
|
||||
| Charter | Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent. | `process` | Read+Write | [spec-kit-charter](https://github.com/Fyloss/spec-kit-charter) |
|
||||
| CI Guard | Spec compliance gates for CI/CD — verify specs exist, check drift, and block merges on gaps | `process` | Read-only | [spec-kit-ci-guard](https://github.com/Quratulain-bilal/spec-kit-ci-guard) |
|
||||
| Checkpoint Extension | Commit the changes made during the middle of the implementation, so you don't end up with just one very large commit at the end | `code` | Read+Write | [spec-kit-checkpoint](https://github.com/aaronrsun/spec-kit-checkpoint) |
|
||||
| Cleanup Extension | Post-implementation quality gate that reviews changes, fixes small issues (scout rule), creates tasks for medium issues, and generates analysis for large issues | `code` | Read+Write | [spec-kit-cleanup](https://github.com/dsrednicki/spec-kit-cleanup) |
|
||||
@@ -51,8 +50,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
|
||||
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| EARS Requirements Syntax | Author, lint, and convert requirements using EARS - the five industry-standard sentence patterns for unambiguous, testable requirements | `docs` | Read+Write | [spec-kit-ears](https://github.com/dhruv-15-03/spec-kit-ears) |
|
||||
| DocGuard — CDD Enforcement | Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
| Extensify | Create and validate extensions and extension catalogs | `process` | Read+Write | [extensify](https://github.com/mnriem/spec-kit-extensions/tree/main/extensify) |
|
||||
| Fix Findings | Automated analyze-fix-reanalyze loop that resolves spec findings until clean | `code` | Read+Write | [spec-kit-fix-findings](https://github.com/Quratulain-bilal/spec-kit-fix-findings) |
|
||||
| FixIt Extension | Spec-aware bug fixing — maps bugs to spec artifacts, proposes a plan, applies minimal changes | `code` | Read+Write | [spec-kit-fixit](https://github.com/speckit-community/spec-kit-fixit) |
|
||||
@@ -87,7 +85,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
|
||||
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
|
||||
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
|
||||
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
|
||||
@@ -108,7 +105,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Retro Extension | Sprint retrospective analysis with metrics, spec accuracy assessment, and improvement suggestions | `process` | Read+Write | [spec-kit-retro](https://github.com/arunt14/spec-kit-retro) |
|
||||
| Retrospective Extension | Post-implementation retrospective with spec adherence scoring, drift analysis, and human-gated spec updates | `docs` | Read+Write | [spec-kit-retrospective](https://github.com/emi-dm/spec-kit-retrospective) |
|
||||
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
|
||||
| Ripple | Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
||||
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
|
||||
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
|
||||
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
|
||||
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-08T00:00:00Z",
|
||||
"updated_at": "2026-07-01T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -670,40 +670,6 @@
|
||||
"created_at": "2026-04-11T18:00:00Z",
|
||||
"updated_at": "2026-04-11T18:00:00Z"
|
||||
},
|
||||
"charter": {
|
||||
"name": "Charter",
|
||||
"id": "charter",
|
||||
"description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
|
||||
"author": "Fyloss",
|
||||
"version": "0.3.1",
|
||||
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.3.1.zip",
|
||||
"repository": "https://github.com/Fyloss/spec-kit-charter",
|
||||
"homepage": "https://github.com/Fyloss/spec-kit-charter",
|
||||
"documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
|
||||
"changelog": "https://github.com/Fyloss/spec-kit-charter/blob/master/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.11.9"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"constitution",
|
||||
"governance",
|
||||
"modular",
|
||||
"fragments",
|
||||
"registry"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-06T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"ci-guard": {
|
||||
"name": "CI Guard",
|
||||
"id": "ci-guard",
|
||||
@@ -1106,10 +1072,10 @@
|
||||
"docguard": {
|
||||
"name": "DocGuard — CDD Enforcement",
|
||||
"id": "docguard",
|
||||
"description": "The only doc-integrity engine with an MCP server, SARIF output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 24 validators, stable finding codes, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep.",
|
||||
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
|
||||
"author": "raccioly",
|
||||
"version": "0.30.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.30.0/spec-kit-docguard-v0.30.0.zip",
|
||||
"version": "0.28.0",
|
||||
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.0.zip",
|
||||
"repository": "https://github.com/raccioly/docguard",
|
||||
"homepage": "https://www.npmjs.com/package/docguard-cli",
|
||||
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
|
||||
@@ -1145,7 +1111,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
"updated_at": "2026-06-23T00:00:00Z"
|
||||
},
|
||||
"doctor": {
|
||||
"name": "Project Health Check",
|
||||
@@ -1180,39 +1146,6 @@
|
||||
"created_at": "2026-03-13T00:00:00Z",
|
||||
"updated_at": "2026-03-13T00:00:00Z"
|
||||
},
|
||||
"ears": {
|
||||
"name": "EARS Requirements Syntax",
|
||||
"id": "ears",
|
||||
"description": "Author, lint, and convert requirements using EARS (Easy Approach to Requirements Syntax) - the five industry-standard sentence patterns for unambiguous, testable requirements.",
|
||||
"author": "dhruv-15-03",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/dhruv-15-03/spec-kit-ears/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/dhruv-15-03/spec-kit-ears",
|
||||
"homepage": "https://github.com/dhruv-15-03/spec-kit-ears",
|
||||
"documentation": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/README.md",
|
||||
"changelog": "https://github.com/dhruv-15-03/spec-kit-ears/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.9.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"ears",
|
||||
"requirements",
|
||||
"specification",
|
||||
"quality"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-08T00:00:00Z",
|
||||
"updated_at": "2026-07-08T00:00:00Z"
|
||||
},
|
||||
"extensify": {
|
||||
"name": "Extensify",
|
||||
"id": "extensify",
|
||||
@@ -2429,39 +2362,6 @@
|
||||
"created_at": "2026-04-03T00:00:00Z",
|
||||
"updated_at": "2026-04-03T00:00:00Z"
|
||||
},
|
||||
"orchestration-task-context-management": {
|
||||
"name": "Orchestration Task Context Management",
|
||||
"id": "orchestration-task-context-management",
|
||||
"description": "Adds subagent work-unit orchestration to generated Spec Kit task files",
|
||||
"author": "Igor Benicio de Mesquita",
|
||||
"version": "0.0.0",
|
||||
"download_url": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/archive/refs/tags/v0.0.0.zip",
|
||||
"repository": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
|
||||
"homepage": "https://github.com/benizzio/spec-kit-orchestration-task-context-management",
|
||||
"documentation": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/README.md",
|
||||
"changelog": "https://github.com/benizzio/spec-kit-orchestration-task-context-management/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.7.2"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"agent",
|
||||
"orchestration",
|
||||
"tasks",
|
||||
"context"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-06T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"orchestrator": {
|
||||
"name": "Spec Orchestrator",
|
||||
"id": "orchestrator",
|
||||
@@ -2779,8 +2679,8 @@
|
||||
"id": "ralph",
|
||||
"description": "Autonomous implementation loop using AI agent CLI",
|
||||
"author": "Rubiss",
|
||||
"version": "1.2.1",
|
||||
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.2.1.zip",
|
||||
"version": "1.1.1",
|
||||
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.1.1.zip",
|
||||
"repository": "https://github.com/Rubiss-Projects/spec-kit-ralph",
|
||||
"homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph",
|
||||
"documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md",
|
||||
@@ -2789,7 +2689,7 @@
|
||||
"category": "code",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.5",
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "copilot",
|
||||
@@ -2799,10 +2699,6 @@
|
||||
"name": "codex",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "claude",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
@@ -2818,14 +2714,13 @@
|
||||
"automation",
|
||||
"loop",
|
||||
"copilot",
|
||||
"codex",
|
||||
"claude"
|
||||
"codex"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-09T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
"updated_at": "2026-06-05T03:11:06Z"
|
||||
},
|
||||
"reconcile": {
|
||||
"name": "Reconcile Extension",
|
||||
@@ -3152,10 +3047,10 @@
|
||||
"ripple": {
|
||||
"name": "Ripple",
|
||||
"id": "ripple",
|
||||
"description": "Detect side effects that tests can't catch after implementation — surface hidden ripple effects across 9 analysis categories",
|
||||
"description": "Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories with fix-induced side effect detection",
|
||||
"author": "chordpli",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.1.0.zip",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/chordpli/spec-kit-ripple/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/chordpli/spec-kit-ripple",
|
||||
"homepage": "https://github.com/chordpli/spec-kit-ripple",
|
||||
"documentation": "https://github.com/chordpli/spec-kit-ripple/blob/main/README.md",
|
||||
@@ -3164,13 +3059,7 @@
|
||||
"category": "code",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0",
|
||||
"tools": [
|
||||
{
|
||||
"name": "git",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
@@ -3187,7 +3076,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-20T00:00:00Z",
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
"updated_at": "2026-04-20T00:00:00Z"
|
||||
},
|
||||
"roadmap": {
|
||||
"name": "Spec Roadmap",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.12.8.dev0"
|
||||
version = "0.12.5"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Consolidated prerequisite checking script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
HELP_TEXT = """Usage: check_prerequisites.py [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
--json Output in JSON format
|
||||
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
--paths-only Only output path variables (no prerequisite validation)
|
||||
--help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
./check_prerequisites.py --json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
./check_prerequisites.py --json --require-tasks --include-tasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
./check_prerequisites.py --paths-only
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Args:
|
||||
json_mode: bool = False
|
||||
require_tasks: bool = False
|
||||
include_tasks: bool = False
|
||||
paths_only: bool = False
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> Args:
|
||||
json_mode = False
|
||||
require_tasks = False
|
||||
include_tasks = False
|
||||
paths_only = False
|
||||
|
||||
for arg in argv:
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg == "--require-tasks":
|
||||
require_tasks = True
|
||||
elif arg == "--include-tasks":
|
||||
include_tasks = True
|
||||
elif arg == "--paths-only":
|
||||
paths_only = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(HELP_TEXT)
|
||||
raise SystemExit(0)
|
||||
else:
|
||||
print(
|
||||
f"ERROR: Unknown option '{arg}'. Use --help for usage information.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
return Args(
|
||||
json_mode=json_mode,
|
||||
require_tasks=require_tasks,
|
||||
include_tasks=include_tasks,
|
||||
paths_only=paths_only,
|
||||
)
|
||||
|
||||
|
||||
def _dir_has_entries(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_dir() and any(path.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _available_docs(paths: FeaturePaths, include_tasks: bool) -> list[str]:
|
||||
docs: list[str] = []
|
||||
if paths.research.is_file():
|
||||
docs.append("research.md")
|
||||
if paths.data_model.is_file():
|
||||
docs.append("data-model.md")
|
||||
if _dir_has_entries(paths.contracts_dir):
|
||||
docs.append("contracts/")
|
||||
if paths.quickstart.is_file():
|
||||
docs.append("quickstart.md")
|
||||
if include_tasks and paths.tasks.is_file():
|
||||
docs.append("tasks.md")
|
||||
return docs
|
||||
|
||||
|
||||
def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
|
||||
if json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"REPO_ROOT": str(paths.repo_root),
|
||||
"BRANCH": paths.current_branch,
|
||||
"FEATURE_DIR": str(paths.feature_dir),
|
||||
"FEATURE_SPEC": str(paths.feature_spec),
|
||||
"IMPL_PLAN": str(paths.impl_plan),
|
||||
"TASKS": str(paths.tasks),
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
print(f"REPO_ROOT: {paths.repo_root}")
|
||||
print(f"BRANCH: {paths.current_branch}")
|
||||
print(f"FEATURE_DIR: {paths.feature_dir}")
|
||||
print(f"FEATURE_SPEC: {paths.feature_spec}")
|
||||
print(f"IMPL_PLAN: {paths.impl_plan}")
|
||||
print(f"TASKS: {paths.tasks}")
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
|
||||
print(f"FEATURE_DIR:{paths.feature_dir}")
|
||||
print("AVAILABLE_DOCS:")
|
||||
_check_file(paths.research, "research.md")
|
||||
_check_file(paths.data_model, "data-model.md")
|
||||
_check_dir(paths.contracts_dir, "contracts/")
|
||||
_check_file(paths.quickstart, "quickstart.md")
|
||||
if include_tasks:
|
||||
_check_file(paths.tasks, "tasks.md")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]))
|
||||
|
||||
try:
|
||||
paths = get_feature_paths(
|
||||
no_persist=args.paths_only,
|
||||
script_file=Path(__file__),
|
||||
)
|
||||
except SystemExit as exc:
|
||||
if exc.code == 0:
|
||||
return 0
|
||||
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
|
||||
return int(exc.code) if isinstance(exc.code, int) else 1
|
||||
|
||||
if args.paths_only:
|
||||
_print_paths_only(paths, args.json_mode)
|
||||
return 0
|
||||
|
||||
if not paths.feature_dir.is_dir():
|
||||
print(f"ERROR: Feature directory not found: {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not paths.impl_plan.is_file():
|
||||
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.require_tasks and not paths.tasks.is_file():
|
||||
print(f"ERROR: tasks.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('tasks', paths.repo_root)} first to create the task list.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
docs = _available_docs(paths, args.include_tasks)
|
||||
if args.json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
|
||||
)
|
||||
else:
|
||||
_print_text_results(paths, args.include_tasks)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Shared helpers for Spec Kit Python scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _trim_trailing_separators(value: Path) -> str:
|
||||
text = str(value)
|
||||
while len(text) > 1 and text.endswith((os.sep, "/")):
|
||||
text = text[:-1]
|
||||
return text
|
||||
|
||||
|
||||
def find_specify_root(start_dir: Path | None = None) -> Path | None:
|
||||
current = (start_dir or Path.cwd()).resolve()
|
||||
while True:
|
||||
if (current / ".specify").is_dir():
|
||||
return current
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
return None
|
||||
current = parent
|
||||
|
||||
|
||||
def resolve_specify_init_dir() -> Path:
|
||||
raw = os.environ.get("SPECIFY_INIT_DIR", "")
|
||||
candidate = Path(raw)
|
||||
if not candidate.is_absolute():
|
||||
candidate = Path.cwd() / candidate
|
||||
try:
|
||||
init_root = candidate.resolve(strict=True)
|
||||
except OSError:
|
||||
print(
|
||||
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if not init_root.is_dir():
|
||||
print(
|
||||
f"ERROR: SPECIFY_INIT_DIR does not point to an existing directory: {raw}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
if not (init_root / ".specify").is_dir():
|
||||
print(
|
||||
"ERROR: SPECIFY_INIT_DIR is not a Spec Kit project "
|
||||
f"(no .specify/ directory): {init_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
return init_root
|
||||
|
||||
|
||||
def get_repo_root(script_file: Path | None = None) -> Path:
|
||||
if os.environ.get("SPECIFY_INIT_DIR"):
|
||||
return resolve_specify_init_dir()
|
||||
|
||||
specify_root = find_specify_root()
|
||||
if specify_root is not None:
|
||||
return specify_root
|
||||
|
||||
if script_file is not None:
|
||||
script_root = find_specify_root(script_file.resolve().parent)
|
||||
if script_root is not None:
|
||||
return script_root
|
||||
|
||||
# Installed scripts live at .specify/scripts/python/<script>.py.
|
||||
return script_file.resolve().parents[3]
|
||||
return Path.cwd().resolve()
|
||||
|
||||
|
||||
def get_current_branch() -> str:
|
||||
return os.environ.get("SPECIFY_FEATURE", "")
|
||||
|
||||
|
||||
def read_feature_json_feature_directory(repo_root: Path) -> str:
|
||||
feature_json = repo_root / ".specify" / "feature.json"
|
||||
if not feature_json.is_file():
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return ""
|
||||
value = data.get("feature_directory") if isinstance(data, dict) else None
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _json_dump(data: dict[str, str]) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
||||
value = feature_dir_value
|
||||
try:
|
||||
relative = Path(value)
|
||||
if relative.is_absolute():
|
||||
try:
|
||||
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
value = str(relative)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
current = read_feature_json_feature_directory(repo_root)
|
||||
if current == value:
|
||||
return
|
||||
|
||||
specify_dir = repo_root / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "feature.json").write_text(
|
||||
_json_dump({"feature_directory": value}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeaturePaths:
|
||||
repo_root: Path
|
||||
current_branch: str
|
||||
feature_dir: Path
|
||||
feature_spec: Path
|
||||
impl_plan: Path
|
||||
tasks: Path
|
||||
research: Path
|
||||
data_model: Path
|
||||
quickstart: Path
|
||||
contracts_dir: Path
|
||||
|
||||
|
||||
def get_feature_paths(
|
||||
*, no_persist: bool = False, script_file: Path | None = None
|
||||
) -> FeaturePaths:
|
||||
repo_root = get_repo_root(script_file)
|
||||
current_branch = get_current_branch()
|
||||
|
||||
feature_dir_raw = os.environ.get("SPECIFY_FEATURE_DIRECTORY", "")
|
||||
if feature_dir_raw:
|
||||
feature_dir = Path(feature_dir_raw)
|
||||
if not feature_dir.is_absolute():
|
||||
feature_dir = repo_root / feature_dir
|
||||
if not no_persist:
|
||||
persist_feature_json(repo_root, feature_dir_raw)
|
||||
elif (repo_root / ".specify" / "feature.json").is_file():
|
||||
stored = read_feature_json_feature_directory(repo_root)
|
||||
if not stored:
|
||||
print(
|
||||
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
|
||||
"or ensure .specify/feature.json contains feature_directory.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
feature_dir = Path(stored)
|
||||
if not feature_dir.is_absolute():
|
||||
feature_dir = repo_root / feature_dir
|
||||
else:
|
||||
print(
|
||||
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
|
||||
"or run the specify command to create .specify/feature.json.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not current_branch:
|
||||
current_branch = Path(_trim_trailing_separators(feature_dir)).name
|
||||
|
||||
return FeaturePaths(
|
||||
repo_root=repo_root,
|
||||
current_branch=current_branch,
|
||||
feature_dir=feature_dir,
|
||||
feature_spec=feature_dir / "spec.md",
|
||||
impl_plan=feature_dir / "plan.md",
|
||||
tasks=feature_dir / "tasks.md",
|
||||
research=feature_dir / "research.md",
|
||||
data_model=feature_dir / "data-model.md",
|
||||
quickstart=feature_dir / "quickstart.md",
|
||||
contracts_dir=feature_dir / "contracts",
|
||||
)
|
||||
|
||||
|
||||
def get_invoke_separator(repo_root: Path) -> str:
|
||||
integration_json = repo_root / ".specify" / "integration.json"
|
||||
if not integration_json.is_file():
|
||||
return "."
|
||||
try:
|
||||
state = json.loads(integration_json.read_text(encoding="utf-8"))
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
return entry["invoke_separator"]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return "."
|
||||
|
||||
|
||||
def format_speckit_command(command_name: str, repo_root: Path) -> str:
|
||||
separator = get_invoke_separator(repo_root)
|
||||
name = command_name.lstrip("/")
|
||||
if name.startswith("speckit."):
|
||||
name = name[len("speckit.") :]
|
||||
elif name.startswith("speckit-"):
|
||||
name = name[len("speckit-") :]
|
||||
name = name.replace(".", separator)
|
||||
return f"/speckit{separator}{name}"
|
||||
@@ -127,14 +127,7 @@ def resolve_github_release_asset_api_url(
|
||||
if hostname == "github.com":
|
||||
api_base = "https://api.github.com"
|
||||
elif is_ghes:
|
||||
# ``parsed.port`` raises ValueError on a malformed port (e.g.
|
||||
# ``host:notaport``); the function's contract is to return None for
|
||||
# anything it can't resolve, not to raise.
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
authority = hostname if port is None else f"{hostname}:{port}"
|
||||
authority = hostname if parsed.port is None else f"{hostname}:{parsed.port}"
|
||||
api_base = f"{parsed.scheme}://{authority}/api/v3"
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -148,9 +148,7 @@ class CommandRegistrar:
|
||||
)
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
def _adjust_script_paths(
|
||||
self, frontmatter: dict, extension_id: Optional[str] = None
|
||||
) -> dict:
|
||||
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
||||
"""Normalize script paths in frontmatter to generated project locations.
|
||||
|
||||
Rewrites known repo-relative and top-level script paths under the
|
||||
@@ -160,7 +158,6 @@ class CommandRegistrar:
|
||||
|
||||
Args:
|
||||
frontmatter: Frontmatter dictionary
|
||||
extension_id: Extension id when rendering extension-owned commands.
|
||||
|
||||
Returns:
|
||||
Modified frontmatter with normalized project paths
|
||||
@@ -171,15 +168,11 @@ class CommandRegistrar:
|
||||
if isinstance(scripts, dict):
|
||||
for key, script_path in scripts.items():
|
||||
if isinstance(script_path, str):
|
||||
scripts[key] = self.rewrite_project_relative_paths(
|
||||
script_path, extension_id=extension_id
|
||||
)
|
||||
scripts[key] = self.rewrite_project_relative_paths(script_path)
|
||||
return frontmatter
|
||||
|
||||
@staticmethod
|
||||
def rewrite_project_relative_paths(
|
||||
text: str, extension_id: Optional[str] = None
|
||||
) -> str:
|
||||
def rewrite_project_relative_paths(text: str) -> str:
|
||||
"""Rewrite repo-relative paths to their generated project locations."""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
@@ -191,18 +184,10 @@ class CommandRegistrar:
|
||||
):
|
||||
text = text.replace(old, new)
|
||||
|
||||
# Only rewrite top-level style references so existing generated paths
|
||||
# like ".specify/extensions/<ext>/scripts/..." remain intact. When
|
||||
# rendering extension commands, top-level "scripts/" is extension-local.
|
||||
scripts_replacement = (
|
||||
f".specify/extensions/{extension_id}/scripts/"
|
||||
if extension_id
|
||||
else ".specify/scripts/"
|
||||
)
|
||||
# Only rewrite top-level style references so extension-local paths like
|
||||
# ".specify/extensions/<ext>/scripts/..." remain intact.
|
||||
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
|
||||
text = re.sub(
|
||||
r'(^|[\s`"\'(])(?:\.?/)?scripts/', rf"\1{scripts_replacement}", text
|
||||
)
|
||||
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
|
||||
text = re.sub(
|
||||
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
|
||||
)
|
||||
@@ -327,7 +312,6 @@ class CommandRegistrar:
|
||||
source_id: str,
|
||||
source_file: str,
|
||||
project_root: Path,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Render a command override as a SKILL.md file.
|
||||
|
||||
@@ -347,7 +331,7 @@ class CommandRegistrar:
|
||||
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root, extension_id=extension_id
|
||||
agent_name, frontmatter, body, project_root
|
||||
)
|
||||
|
||||
description = frontmatter.get(
|
||||
@@ -409,11 +393,7 @@ class CommandRegistrar:
|
||||
|
||||
@staticmethod
|
||||
def resolve_skill_placeholders(
|
||||
agent_name: str,
|
||||
frontmatter: dict,
|
||||
body: str,
|
||||
project_root: Path,
|
||||
extension_id: Optional[str] = None,
|
||||
agent_name: str, frontmatter: dict, body: str, project_root: Path
|
||||
) -> str:
|
||||
"""Resolve script placeholders for skills-backed agents."""
|
||||
if not isinstance(frontmatter, dict):
|
||||
@@ -453,9 +433,7 @@ class CommandRegistrar:
|
||||
|
||||
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
|
||||
|
||||
return CommandRegistrar.rewrite_project_relative_paths(
|
||||
body, extension_id=extension_id
|
||||
)
|
||||
return CommandRegistrar.rewrite_project_relative_paths(body)
|
||||
|
||||
def _convert_argument_placeholder(
|
||||
self, content: str, from_placeholder: str, to_placeholder: str
|
||||
@@ -550,7 +528,6 @@ class CommandRegistrar:
|
||||
context_note: str = None,
|
||||
_resolved_dir: Path = None,
|
||||
link_outputs: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""Register commands for a specific agent.
|
||||
|
||||
@@ -568,7 +545,6 @@ class CommandRegistrar:
|
||||
link_outputs: If True, write rendered output to a source-local
|
||||
dev cache and symlink the agent command file to it. Falls back
|
||||
to a normal file write when symlinks are unavailable.
|
||||
extension_id: Extension id when rendering extension-owned commands.
|
||||
|
||||
Returns:
|
||||
List of registered command names
|
||||
@@ -638,9 +614,7 @@ class CommandRegistrar:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
frontmatter.pop("strategy", None)
|
||||
|
||||
frontmatter = self._adjust_script_paths(
|
||||
frontmatter, extension_id=extension_id
|
||||
)
|
||||
frontmatter = self._adjust_script_paths(frontmatter)
|
||||
|
||||
for key in agent_config.get("strip_frontmatter_keys", []):
|
||||
frontmatter.pop(key, None)
|
||||
@@ -679,11 +653,10 @@ class CommandRegistrar:
|
||||
source_id,
|
||||
cmd_file,
|
||||
project_root,
|
||||
extension_id=extension_id,
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root, extension_id=extension_id
|
||||
agent_name, frontmatter, body, project_root
|
||||
)
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
@@ -693,7 +666,7 @@ class CommandRegistrar:
|
||||
)
|
||||
elif agent_config["format"] == "toml":
|
||||
body = self.resolve_skill_placeholders(
|
||||
agent_name, frontmatter, body, project_root, extension_id=extension_id
|
||||
agent_name, frontmatter, body, project_root
|
||||
)
|
||||
body = self._convert_argument_placeholder(
|
||||
body, "$ARGUMENTS", agent_config["args"]
|
||||
@@ -706,17 +679,6 @@ class CommandRegistrar:
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {agent_config['format']}")
|
||||
|
||||
# -- Post-process for non-skills agents -----------------------
|
||||
_integration = None
|
||||
if agent_config["extension"] != "/SKILL.md":
|
||||
from specify_cli.integrations import ( # noqa: PLC0415
|
||||
get_integration,
|
||||
)
|
||||
|
||||
_integration = get_integration(agent_name)
|
||||
if _integration is not None:
|
||||
output = _integration.post_process_command_content(output)
|
||||
|
||||
dest_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
self._ensure_inside(dest_file, commands_dir)
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -759,7 +721,6 @@ class CommandRegistrar:
|
||||
source_id,
|
||||
cmd_file,
|
||||
project_root,
|
||||
extension_id=extension_id,
|
||||
)
|
||||
elif agent_config["format"] == "markdown":
|
||||
alias_output = self.render_markdown_command(
|
||||
@@ -777,9 +738,6 @@ class CommandRegistrar:
|
||||
raise ValueError(
|
||||
f"Unsupported format: {agent_config['format']}"
|
||||
)
|
||||
|
||||
if agent_config["extension"] != "/SKILL.md" and _integration is not None:
|
||||
alias_output = _integration.post_process_command_content(alias_output)
|
||||
else:
|
||||
# For other agents, reuse the primary output
|
||||
alias_output = output
|
||||
@@ -792,7 +750,6 @@ class CommandRegistrar:
|
||||
source_id,
|
||||
cmd_file,
|
||||
project_root,
|
||||
extension_id=extension_id,
|
||||
)
|
||||
|
||||
alias_file = (
|
||||
@@ -924,7 +881,6 @@ class CommandRegistrar:
|
||||
context_note: str = None,
|
||||
link_outputs: bool = False,
|
||||
create_missing_active_skills_dir: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all detected agents in the project.
|
||||
|
||||
@@ -941,7 +897,6 @@ class CommandRegistrar:
|
||||
Recovery requires active skills mode (or Kimi's existing native
|
||||
skills directory) and is skipped when safe resolution or
|
||||
creation fails.
|
||||
extension_id: Extension id when rendering extension-owned commands.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
@@ -1044,7 +999,6 @@ class CommandRegistrar:
|
||||
context_note=context_note,
|
||||
_resolved_dir=agent_dir,
|
||||
link_outputs=link_outputs,
|
||||
extension_id=extension_id,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
@@ -1069,7 +1023,6 @@ class CommandRegistrar:
|
||||
project_root: Path,
|
||||
context_note: Optional[str] = None,
|
||||
link_outputs: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all non-skill agents in the project.
|
||||
|
||||
@@ -1085,7 +1038,6 @@ class CommandRegistrar:
|
||||
context_note: Custom context comment for markdown output
|
||||
link_outputs: If True, create dev-mode symlinks for rendered
|
||||
command files when supported by the OS.
|
||||
extension_id: Extension id when rendering extension-owned commands.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
@@ -1114,7 +1066,6 @@ class CommandRegistrar:
|
||||
context_note=context_note,
|
||||
_resolved_dir=agent_dir,
|
||||
link_outputs=link_outputs,
|
||||
extension_id=extension_id,
|
||||
)
|
||||
if registered:
|
||||
results[agent_name] = registered
|
||||
|
||||
@@ -73,13 +73,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
self._redirect_validator = redirect_validator
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
try:
|
||||
new_parsed = urlparse(newurl)
|
||||
except ValueError as exc:
|
||||
# Malformed redirect target (e.g. unterminated IPv6 bracket).
|
||||
# Surface as URLError so callers' download error handling applies.
|
||||
raise urllib.error.URLError(f"malformed redirect URL: {exc}") from exc
|
||||
|
||||
if self._redirect_validator is not None:
|
||||
self._redirect_validator(req.full_url, newurl)
|
||||
|
||||
@@ -90,6 +83,7 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
if new_req is not None:
|
||||
old_scheme = urlparse(req.full_url).scheme
|
||||
new_parsed = urlparse(newurl)
|
||||
hostname = (new_parsed.hostname or "").lower()
|
||||
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
|
||||
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
|
||||
|
||||
@@ -95,11 +95,7 @@ def _is_local_path(url: str) -> bool:
|
||||
"""True when *url* denotes a local filesystem path rather than a URL."""
|
||||
if _WINDOWS_DRIVE_RE.match(url):
|
||||
return True
|
||||
try:
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
except ValueError:
|
||||
# Malformed URLs (e.g. an unclosed IPv6 bracket) are not local paths.
|
||||
return False
|
||||
scheme = urlparse(url).scheme.lower()
|
||||
return scheme not in _REMOTE_SCHEMES
|
||||
|
||||
|
||||
@@ -141,10 +137,7 @@ def add_source(
|
||||
url = url.strip()
|
||||
if not url:
|
||||
raise BundlerError("A catalog url is required.")
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError as exc:
|
||||
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
|
||||
parsed = urlparse(url)
|
||||
if not (parsed.scheme or parsed.path):
|
||||
raise BundlerError(f"Invalid catalog url: '{url}'.")
|
||||
# Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are
|
||||
@@ -155,20 +148,6 @@ def add_source(
|
||||
f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. "
|
||||
"Use http(s)://, file://, builtin://, or a local path."
|
||||
)
|
||||
if parsed.scheme.lower() in {"http", "https"}:
|
||||
# Mirror specify_cli.catalogs._validate_catalog_url (#3209/#3210):
|
||||
# HTTPS only (HTTP just for localhost), and check hostname, not
|
||||
# netloc — netloc is truthy for host-less URLs like "https://:8080"
|
||||
# or "https://user@". Validating here keeps junk out of
|
||||
# bundle-catalogs.yml instead of failing later at fetch time.
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme.lower() != "https" and not is_localhost:
|
||||
raise BundlerError(
|
||||
f"Catalog url must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
|
||||
|
||||
url = _canonicalize_url(url)
|
||||
install_policy = InstallPolicy.parse(policy)
|
||||
|
||||
@@ -130,28 +130,6 @@ def install_bundle(
|
||||
done.append(component)
|
||||
result.installed.append(component)
|
||||
contributed.append(component)
|
||||
|
||||
# On update (refresh), uninstall components this bundle used to own
|
||||
# that the new version no longer ships. Otherwise they are dropped
|
||||
# from the record below (contributed only holds plan.components) yet
|
||||
# left on disk — permanently orphaned, since no bundle record can
|
||||
# ever remove them. A stale component still owned by another bundle
|
||||
# is kept installed and simply de-attributed here (it stays in that
|
||||
# bundle's record). Mirrors remove_bundle's refcount logic.
|
||||
if refresh and existing is not None:
|
||||
planned = {(c.kind, c.id) for c in plan.components}
|
||||
still_needed = components_still_needed(
|
||||
records, exclude_bundle_id=plan.bundle_id
|
||||
)
|
||||
for component in existing.contributed_components:
|
||||
key = (component.kind, component.id)
|
||||
if key in planned:
|
||||
continue
|
||||
if key in still_needed:
|
||||
continue
|
||||
if installer.is_installed(project_root, component):
|
||||
installer.remove(project_root, component)
|
||||
result.uninstalled.append(component)
|
||||
except BundlerError:
|
||||
_rollback(project_root, installer, done)
|
||||
raise
|
||||
|
||||
@@ -1075,11 +1075,9 @@ class ExtensionManager:
|
||||
pass # best-effort cleanup
|
||||
continue
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
frontmatter = registrar._adjust_script_paths(
|
||||
frontmatter, extension_id=manifest.id
|
||||
)
|
||||
frontmatter = registrar._adjust_script_paths(frontmatter)
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
|
||||
selected_ai, frontmatter, body, self.project_root
|
||||
)
|
||||
|
||||
original_desc = frontmatter.get("description", "")
|
||||
@@ -1960,7 +1958,6 @@ class CommandRegistrar:
|
||||
project_root,
|
||||
context_note=context_note,
|
||||
link_outputs=link_outputs,
|
||||
extension_id=manifest.id,
|
||||
)
|
||||
|
||||
def register_commands_for_all_agents(
|
||||
@@ -1981,7 +1978,6 @@ class CommandRegistrar:
|
||||
context_note=context_note,
|
||||
link_outputs=link_outputs,
|
||||
create_missing_active_skills_dir=create_missing_active_skills_dir,
|
||||
extension_id=manifest.id,
|
||||
)
|
||||
|
||||
def unregister_commands(
|
||||
@@ -2692,12 +2688,7 @@ class ConfigManager:
|
||||
return {}
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(file_path.read_text(encoding="utf-8"))
|
||||
# Coerce a non-mapping root (list/scalar, or None for an empty
|
||||
# file) to {} so callers that iterate/merge the result — e.g.
|
||||
# _merge_configs' .items() — never crash. Mirrors the same
|
||||
# non-dict-root guard in get_project_config().
|
||||
return data if isinstance(data, dict) else {}
|
||||
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
|
||||
except (yaml.YAMLError, OSError, UnicodeError):
|
||||
return {}
|
||||
|
||||
|
||||
@@ -426,11 +426,7 @@ def extension_add(
|
||||
if from_url and not dev:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
parsed = urlparse(from_url)
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
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):
|
||||
|
||||
@@ -89,11 +89,7 @@ class AgyIntegration(SkillsIntegration):
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
# agy does not support --model or JSON output; both params are ignored
|
||||
args = [self._resolve_executable(), "--print", prompt]
|
||||
# Honor SPECKIT_INTEGRATION_AGY_EXTRA_ARGS (operator-supplied flags),
|
||||
# appended after the positional prompt like the devin integration.
|
||||
self._apply_extra_args_env_var(args)
|
||||
return args
|
||||
return [self._resolve_executable(), "--print", prompt]
|
||||
|
||||
def setup(
|
||||
self,
|
||||
|
||||
@@ -123,19 +123,6 @@ class IntegrationBase(ABC):
|
||||
integration that sets this flag.
|
||||
"""
|
||||
|
||||
def post_process_command_content(self, content: str) -> str:
|
||||
"""Transform command content after format rendering.
|
||||
|
||||
Called by ``register_commands()`` for non-skills format types
|
||||
(Markdown, TOML, YAML) after the command has been rendered into
|
||||
its target format and before writing to disk. Skills-format
|
||||
agents use ``post_process_skill_content()`` instead.
|
||||
|
||||
Subclasses may override to inject agent-specific content.
|
||||
The default implementation returns *content* unchanged.
|
||||
"""
|
||||
return content
|
||||
|
||||
# -- Public API -------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
@@ -1191,18 +1178,12 @@ class YamlIntegration(IntegrationBase):
|
||||
default_flow_style=False,
|
||||
).strip()
|
||||
|
||||
# Indent the body for YAML block scalar. Use an explicit indentation
|
||||
# indicator ("|2") rather than a bare "|": YAML infers a plain block
|
||||
# scalar's indentation from its first non-empty line, so a body whose
|
||||
# first line is itself indented (e.g. a markdown code block or a nested
|
||||
# list item) would make the parser expect that deeper indent for the
|
||||
# whole block and reject the later, less-indented lines. Pinning the
|
||||
# indent to 2 keeps the recipe parseable whatever the body looks like.
|
||||
# Indent the body for YAML block scalar
|
||||
indented = "\n".join(f" {line}" for line in body.split("\n"))
|
||||
|
||||
lines = [
|
||||
header_yaml,
|
||||
"prompt: |2",
|
||||
"prompt: |",
|
||||
indented,
|
||||
"",
|
||||
f"# Source: {source_id}",
|
||||
|
||||
@@ -253,11 +253,6 @@ class HermesIntegration(SkillsIntegration):
|
||||
"""
|
||||
args = [self._resolve_executable(), "chat", "-Q"]
|
||||
|
||||
# Operator-supplied SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS go here —
|
||||
# after the base command but before Spec Kit's canonical -m/--json/-s/-q
|
||||
# flags — so they can't displace or clobber them (mirrors opencode).
|
||||
self._apply_extra_args_env_var(args)
|
||||
|
||||
if model:
|
||||
args.extend(["-m", model])
|
||||
if output_json:
|
||||
|
||||
@@ -309,14 +309,7 @@ class IntegrationManifest:
|
||||
if abs_path.is_symlink() or not abs_path.is_file():
|
||||
modified.append(rel)
|
||||
continue
|
||||
try:
|
||||
changed = _sha256(abs_path) != expected_hash
|
||||
except OSError:
|
||||
# Unreadable regular file (e.g. permission denied): treat as
|
||||
# modified, consistent with the symlink / non-regular-file
|
||||
# handling above, rather than letting the OSError escape.
|
||||
changed = True
|
||||
if changed:
|
||||
if _sha256(abs_path) != expected_hash:
|
||||
modified.append(rel)
|
||||
return modified
|
||||
|
||||
@@ -365,17 +358,9 @@ class IntegrationManifest:
|
||||
skipped.append(path)
|
||||
continue
|
||||
else:
|
||||
if not force:
|
||||
try:
|
||||
matches = _sha256(path) == expected_hash
|
||||
except OSError:
|
||||
# Unreadable: can't verify it's ours, so preserve it
|
||||
# (mirrors the path.unlink() OSError guard below).
|
||||
skipped.append(path)
|
||||
continue
|
||||
if not matches:
|
||||
skipped.append(path)
|
||||
continue
|
||||
if not force and _sha256(path) != expected_hash:
|
||||
skipped.append(path)
|
||||
continue
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
|
||||
@@ -104,13 +104,7 @@ def preset_add(
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
try:
|
||||
_parsed = _urlparse(from_url)
|
||||
except ValueError:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
_parsed = _urlparse(from_url)
|
||||
|
||||
def _is_allowed_download_url(parsed_url):
|
||||
host = parsed_url.hostname
|
||||
@@ -141,9 +135,7 @@ def preset_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
from rich.markup import escape as _esc
|
||||
|
||||
console.print(f"Installing preset from [cyan]{_esc(from_url)}[/cyan]...")
|
||||
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
|
||||
import urllib.error
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
@@ -50,17 +50,7 @@ workflow_step_catalog_app = typer.Typer(
|
||||
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
|
||||
|
||||
|
||||
def _error_console(json_output: bool):
|
||||
"""Console for error text: stderr under ``--json`` so the JSON stdout
|
||||
stream stays parseable, the normal console otherwise. Mirrors the
|
||||
stderr-only error routing already used by ``specify bundle``.
|
||||
"""
|
||||
return err_console if json_output else console
|
||||
|
||||
|
||||
def _parse_input_values(
|
||||
input_values: list[str] | None, *, json_output: bool = False
|
||||
) -> dict[str, Any]:
|
||||
def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
|
||||
"""Parse repeated ``key=value`` CLI inputs into a dict.
|
||||
|
||||
Shared by ``workflow run`` and ``workflow resume``. Exits with an error
|
||||
@@ -69,9 +59,7 @@ def _parse_input_values(
|
||||
inputs: dict[str, Any] = {}
|
||||
for kv in input_values or []:
|
||||
if "=" not in kv:
|
||||
_error_console(json_output).print(
|
||||
f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)"
|
||||
)
|
||||
console.print(f"[red]Error:[/red] Invalid input format: {kv!r} (expected key=value)")
|
||||
raise typer.Exit(1)
|
||||
key, _, value = kv.partition("=")
|
||||
inputs[key.strip()] = value.strip()
|
||||
@@ -347,26 +335,25 @@ def workflow_run(
|
||||
if not json_output:
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
err = _error_console(json_output)
|
||||
try:
|
||||
definition = engine.load_workflow(source_path if is_file_source else source)
|
||||
except FileNotFoundError:
|
||||
err.print(f"[red]Error:[/red] Workflow not found: {source}")
|
||||
console.print(f"[red]Error:[/red] Workflow not found: {source}")
|
||||
raise typer.Exit(1)
|
||||
except ValueError as exc:
|
||||
err.print(f"[red]Error:[/red] Invalid workflow: {exc}")
|
||||
console.print(f"[red]Error:[/red] Invalid workflow: {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate
|
||||
errors = engine.validate(definition)
|
||||
if errors:
|
||||
err.print("[red]Workflow validation failed:[/red]")
|
||||
for verr in errors:
|
||||
err.print(f" • {verr}")
|
||||
console.print("[red]Workflow validation failed:[/red]")
|
||||
for err in errors:
|
||||
console.print(f" • {err}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Parse inputs
|
||||
inputs = _parse_input_values(input_values, json_output=json_output)
|
||||
inputs = _parse_input_values(input_values)
|
||||
|
||||
if not json_output:
|
||||
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
|
||||
@@ -376,10 +363,10 @@ def workflow_run(
|
||||
with _stdout_to_stderr_when(json_output):
|
||||
state = engine.execute(definition, inputs)
|
||||
except ValueError as exc:
|
||||
err.print(f"[red]Error:[/red] {exc}")
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
except Exception as exc:
|
||||
err.print(f"[red]Workflow failed:[/red] {exc}")
|
||||
console.print(f"[red]Workflow failed:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if json_output:
|
||||
@@ -424,20 +411,19 @@ def workflow_resume(
|
||||
if not json_output:
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
inputs = _parse_input_values(input_values, json_output=json_output)
|
||||
err = _error_console(json_output)
|
||||
inputs = _parse_input_values(input_values)
|
||||
|
||||
try:
|
||||
with _stdout_to_stderr_when(json_output):
|
||||
state = engine.resume(run_id, inputs or None)
|
||||
except FileNotFoundError:
|
||||
err.print(f"[red]Error:[/red] Run not found: {run_id}")
|
||||
console.print(f"[red]Error:[/red] Run not found: {run_id}")
|
||||
raise typer.Exit(1)
|
||||
except ValueError as exc:
|
||||
err.print(f"[red]Error:[/red] {exc}")
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
except Exception as exc:
|
||||
err.print(f"[red]Resume failed:[/red] {exc}")
|
||||
console.print(f"[red]Resume failed:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if json_output:
|
||||
@@ -631,11 +617,7 @@ def workflow_add(
|
||||
from urllib.parse import urlparse
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
try:
|
||||
parsed_src = urlparse(source)
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(source)}")
|
||||
raise typer.Exit(1)
|
||||
parsed_src = urlparse(source)
|
||||
src_host = parsed_src.hostname or ""
|
||||
src_loopback = src_host == "localhost"
|
||||
if not src_loopback:
|
||||
|
||||
@@ -58,13 +58,4 @@ class FanInStep(StepBase):
|
||||
f"Fan-in step {config.get('id', '?')!r}: "
|
||||
f"'wait_for' must be a non-empty list of step IDs."
|
||||
)
|
||||
output = config.get("output")
|
||||
if output is not None and not isinstance(output, dict):
|
||||
# execute() silently coerces a non-mapping output to {}, so the
|
||||
# author's declared aggregation keys would vanish with no error.
|
||||
# Reject at validation, mirroring the command-step (#3262) fix.
|
||||
errors.append(
|
||||
f"Fan-in step {config.get('id', '?')!r}: 'output' must be a "
|
||||
f"mapping of key -> expression, got {type(output).__name__}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -90,16 +90,6 @@ class ShellStep(StepBase):
|
||||
errors.append(
|
||||
f"Shell step {config.get('id', '?')!r} is missing 'run' field."
|
||||
)
|
||||
elif not isinstance(config["run"], str):
|
||||
# execute() str()-coerces run and invokes it under shell=True, so a
|
||||
# null or list 'run' would run the Python repr ('None', "['echo']")
|
||||
# as a command. Reject non-strings at validation, mirroring the
|
||||
# command-step input/options and gate options type checks. An
|
||||
# expression like "{{ ... }}" is still a str, so it stays valid.
|
||||
errors.append(
|
||||
f"Shell step {config.get('id', '?')!r}: 'run' must be a string, "
|
||||
f"got {type(config['run']).__name__}."
|
||||
)
|
||||
output_format = config.get("output_format")
|
||||
if output_format is not None and output_format != "json":
|
||||
errors.append(
|
||||
|
||||
@@ -220,68 +220,3 @@ def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
|
||||
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
assert ("extensions", "ext-a") in installer.installed
|
||||
|
||||
|
||||
def _bundle(manifest_id, ext_ids, *, version="1.0.0"):
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = manifest_id
|
||||
data["bundle"]["version"] = version
|
||||
data["provides"] = {
|
||||
"extensions": [{"id": e, "version": version} for e in ext_ids]
|
||||
}
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_update_uninstalls_components_dropped_by_new_version(tmp_path: Path):
|
||||
"""`bundle update` must uninstall components the new version no longer
|
||||
ships, instead of orphaning them (installed on disk, tracked by nothing)."""
|
||||
make_project(tmp_path)
|
||||
installer = FakeInstaller()
|
||||
|
||||
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
|
||||
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
|
||||
assert ("extensions", "ext-b") in installer.installed
|
||||
|
||||
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
|
||||
)
|
||||
|
||||
# ext-b was dropped by v2 -> uninstalled and reported.
|
||||
assert ("extensions", "ext-b") in installer.remove_calls
|
||||
assert ("extensions", "ext-b") in {(c.kind, c.id) for c in result.uninstalled}
|
||||
assert ("extensions", "ext-b") not in installer.installed
|
||||
assert ("extensions", "ext-a") in installer.installed
|
||||
|
||||
# The saved record lists only ext-a.
|
||||
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
|
||||
keys = {(c.kind, c.id) for c in rec.contributed_components}
|
||||
assert ("extensions", "ext-a") in keys
|
||||
assert ("extensions", "ext-b") not in keys
|
||||
|
||||
|
||||
def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
|
||||
"""A dropped component still owned by another bundle stays installed."""
|
||||
make_project(tmp_path)
|
||||
installer = FakeInstaller()
|
||||
|
||||
man_sib = _bundle("sibling", ["ext-b"])
|
||||
install_bundle(tmp_path, _plan(man_sib), installer, manifest=man_sib)
|
||||
|
||||
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
|
||||
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
|
||||
|
||||
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
|
||||
install_bundle(
|
||||
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
|
||||
)
|
||||
|
||||
# ext-b is still needed by 'sibling' -> not removed, stays installed.
|
||||
assert ("extensions", "ext-b") not in installer.remove_calls
|
||||
assert ("extensions", "ext-b") in installer.installed
|
||||
|
||||
# But demo's record no longer attributes it.
|
||||
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
|
||||
assert ("extensions", "ext-b") not in {
|
||||
(c.kind, c.id) for c in rec.contributed_components
|
||||
}
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
"""Shared test helpers for integration tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import MarkdownIntegration
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
"""Keep integration tests from reading or writing the real user home."""
|
||||
home = tmp_path / "home"
|
||||
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache"))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
|
||||
|
||||
|
||||
class StubIntegration(MarkdownIntegration):
|
||||
"""Minimal concrete integration for testing."""
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Regression tests for integration-test environment isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
|
||||
home = tmp_path / "home"
|
||||
|
||||
assert Path(os.environ["HOME"]) == home
|
||||
assert Path(os.environ["USERPROFILE"]) == home
|
||||
assert Path(os.environ["XDG_CACHE_HOME"]) == home / ".cache"
|
||||
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
|
||||
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
|
||||
|
||||
assert home.is_dir()
|
||||
assert (home / ".cache").is_dir()
|
||||
assert (home / ".config").is_dir()
|
||||
assert (home / ".local" / "share").is_dir()
|
||||
@@ -81,26 +81,6 @@ class TestAgyBuildExecArgs:
|
||||
result = i.build_exec_args("my prompt", output_json=False)
|
||||
assert result == ["agy", "--print", "my prompt"]
|
||||
|
||||
def test_build_exec_args_honors_extra_args(self, monkeypatch):
|
||||
"""SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be appended after the prompt.
|
||||
|
||||
agy previously skipped _apply_extra_args_env_var entirely, so the
|
||||
documented per-integration extra-args hook was silently ignored
|
||||
(same class as the merged cursor-agent fix #3265).
|
||||
"""
|
||||
from specify_cli.integrations import get_integration
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--verbose")
|
||||
i = get_integration("agy")
|
||||
assert i.build_exec_args("my prompt") == [
|
||||
"agy", "--print", "my prompt", "--verbose",
|
||||
]
|
||||
|
||||
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
||||
from specify_cli.integrations import get_integration
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXECUTABLE", "/custom/agy")
|
||||
i = get_integration("agy")
|
||||
assert i.build_exec_args("my prompt")[0] == "/custom/agy"
|
||||
|
||||
|
||||
class TestAgyHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected into hook sections."""
|
||||
|
||||
@@ -184,23 +184,6 @@ class YamlIntegrationTests:
|
||||
assert "scripts:" not in parsed["prompt"]
|
||||
assert "---" not in parsed["prompt"]
|
||||
|
||||
def test_yaml_prompt_with_indented_first_line_stays_valid(self):
|
||||
"""A body whose first line is indented must still parse.
|
||||
|
||||
A bare ``|`` block scalar infers its indentation from the first
|
||||
non-empty line, so a body starting with an indented line (e.g. a
|
||||
markdown code block or nested list item) made the parser expect that
|
||||
deeper indent for the whole block and reject the later, shallower
|
||||
lines. The explicit ``|2`` indicator pins the indent so it parses."""
|
||||
body = " indented first line\nback to normal\n indented again"
|
||||
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
|
||||
|
||||
yaml_lines = [
|
||||
ln for ln in rendered.split("\n") if not ln.startswith("# Source:")
|
||||
]
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
assert parsed["prompt"].rstrip("\n") == body
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
|
||||
@@ -353,38 +353,3 @@ class TestHermesInitFlow:
|
||||
if "agent-context" not in d.name
|
||||
]
|
||||
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
|
||||
|
||||
|
||||
class TestHermesBuildExecArgs:
|
||||
"""CLI dispatch argv, including the operator extra-args env hook."""
|
||||
|
||||
def test_build_exec_args_default_shape(self):
|
||||
i = get_integration("hermes")
|
||||
assert i.build_exec_args("/speckit-plan hi", output_json=True) == [
|
||||
"hermes", "chat", "-Q", "--json", "-s", "speckit-plan", "-q", "hi",
|
||||
]
|
||||
|
||||
def test_build_exec_args_honors_extra_args(self, monkeypatch):
|
||||
"""SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS is injected before the
|
||||
canonical -m/--json/-s/-q flags (same env hook as codex/opencode/
|
||||
devin; hermes previously skipped _apply_extra_args_env_var entirely).
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS", "--temperature 0.2"
|
||||
)
|
||||
i = get_integration("hermes")
|
||||
args = i.build_exec_args("/speckit-plan hi", output_json=True)
|
||||
assert args == [
|
||||
"hermes", "chat", "-Q", "--temperature", "0.2",
|
||||
"--json", "-s", "speckit-plan", "-q", "hi",
|
||||
]
|
||||
# Injected before the canonical flags so it can't displace them.
|
||||
assert args.index("--temperature") < args.index("--json")
|
||||
assert args.index("--temperature") < args.index("-s")
|
||||
|
||||
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_HERMES_EXECUTABLE", "/custom/hermes"
|
||||
)
|
||||
i = get_integration("hermes")
|
||||
assert i.build_exec_args("/speckit-plan hi")[0] == "/custom/hermes"
|
||||
|
||||
@@ -481,40 +481,3 @@ class TestRecordExistingNewGuards:
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
|
||||
m.record_existing("dir/../file.txt")
|
||||
|
||||
|
||||
class TestManifestUnreadableFile:
|
||||
"""A managed file that is unreadable (e.g. PermissionError) must not crash
|
||||
check_modified()/uninstall() — the CLI handlers surfaced a raw traceback."""
|
||||
|
||||
def _mk(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("sub/f.md", "content")
|
||||
return m
|
||||
|
||||
def test_check_modified_treats_unreadable_as_modified(self, tmp_path, monkeypatch):
|
||||
m = self._mk(tmp_path)
|
||||
|
||||
def raise_perm(_path):
|
||||
raise PermissionError("unreadable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.manifest._sha256", raise_perm
|
||||
)
|
||||
# Before the fix this raised PermissionError.
|
||||
assert m.check_modified() == ["sub/f.md"]
|
||||
|
||||
def test_uninstall_preserves_unreadable_file(self, tmp_path, monkeypatch):
|
||||
m = self._mk(tmp_path)
|
||||
|
||||
def raise_perm(_path):
|
||||
raise PermissionError("unreadable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.manifest._sha256", raise_perm
|
||||
)
|
||||
removed, skipped = m.uninstall(force=False)
|
||||
# Can't verify ownership => preserve, don't crash and don't delete.
|
||||
assert removed == []
|
||||
assert (tmp_path / "sub" / "f.md") in skipped
|
||||
assert (tmp_path / "sub" / "f.md").exists()
|
||||
|
||||
@@ -48,19 +48,6 @@ def _multi_install_safe_pairs() -> list[tuple[str, str]]:
|
||||
]
|
||||
|
||||
|
||||
def _multi_install_safe_orders() -> list[list[str]]:
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
if len(safe_keys) < 2:
|
||||
return [safe_keys]
|
||||
return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))]
|
||||
|
||||
|
||||
def _multi_install_safe_order_id(ordered_keys: list[str]) -> str:
|
||||
if not ordered_keys:
|
||||
return "no-safe-integrations"
|
||||
return f"init-{ordered_keys[0]}"
|
||||
|
||||
|
||||
def _posix_path(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
@@ -100,6 +87,16 @@ def _paths_overlap(first: str | None, second: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _path_is_inside(path: str | None, directory: str | None) -> bool:
|
||||
if not path or not directory:
|
||||
return False
|
||||
try:
|
||||
PurePosixPath(path).relative_to(PurePosixPath(directory))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_registry_is_dict(self):
|
||||
assert isinstance(INTEGRATION_REGISTRY, dict)
|
||||
@@ -165,15 +162,6 @@ class TestRegistrarKeyAlignment:
|
||||
class TestMultiInstallSafeContracts:
|
||||
"""Declared safe integrations must stay isolated from each other."""
|
||||
|
||||
def test_safe_install_orders_rotate_each_integration_through_init(self):
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
orders = _multi_install_safe_orders()
|
||||
|
||||
assert len(safe_keys) >= 2
|
||||
assert [order[0] for order in orders] == safe_keys
|
||||
assert len({tuple(order) for order in orders}) == len(safe_keys)
|
||||
assert all(sorted(order) == safe_keys for order in orders)
|
||||
|
||||
@pytest.mark.parametrize("key", _multi_install_safe_keys())
|
||||
def test_safe_integrations_have_static_isolated_paths(self, key):
|
||||
assert _integration_root_dir(key), (
|
||||
@@ -199,77 +187,62 @@ class TestMultiInstallSafeContracts:
|
||||
f"{_integration_commands_dir(second)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ordered_keys",
|
||||
_multi_install_safe_orders(),
|
||||
ids=_multi_install_safe_order_id,
|
||||
)
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_integrations_have_disjoint_manifests(
|
||||
self,
|
||||
tmp_path,
|
||||
ordered_keys,
|
||||
first,
|
||||
second,
|
||||
):
|
||||
# The pairwise disjointness contract is only meaningful with at least
|
||||
# two safe integrations. Guard so a shrunken registry fails loudly here
|
||||
# rather than passing vacuously (or tripping over ordered_keys[0] below).
|
||||
assert len(ordered_keys) >= 2, (
|
||||
f"expected at least two multi-install-safe integrations, got {ordered_keys}"
|
||||
)
|
||||
for initial, additional in ((first, second), (second, first)):
|
||||
project_root = tmp_path / f"project-{initial}-{additional}"
|
||||
project_root.mkdir()
|
||||
runner = CliRunner()
|
||||
|
||||
project_root = tmp_path / "project"
|
||||
project_root.mkdir()
|
||||
runner = CliRunner()
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
init_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
initial,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert init_result.exit_code == 0, init_result.output
|
||||
|
||||
# Install every safe integration once into a single project, then assert
|
||||
# pairwise manifest isolation. Each safe integration writes only to its
|
||||
# own (disjoint) directories and always records what it writes, so a
|
||||
# manifest's contents are independent of install order and of which other
|
||||
# integrations are co-installed. The parametrized rotations keep the
|
||||
# aggregate setup while placing each safe integration first once, so each
|
||||
# one still exercises the `specify init --integration ...` path.
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
init_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
ordered_keys[0],
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert init_result.exit_code == 0, init_result.output
|
||||
|
||||
for key in ordered_keys[1:]:
|
||||
install_result = runner.invoke(
|
||||
app,
|
||||
["integration", "install", key, "--script", "sh"],
|
||||
["integration", "install", additional, "--script", "sh"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert install_result.exit_code == 0, install_result.output
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
integrations_dir = project_root / ".specify" / "integrations"
|
||||
manifests = {}
|
||||
for key in ordered_keys:
|
||||
manifest = json.loads(
|
||||
(integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8")
|
||||
initial_manifest = json.loads(
|
||||
(
|
||||
project_root / ".specify" / "integrations" / f"{initial}.manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
additional_manifest = json.loads(
|
||||
(
|
||||
project_root / ".specify" / "integrations" / f"{additional}.manifest.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
files = manifest.get("files", {})
|
||||
assert isinstance(files, dict), f"{key} manifest files must be an object"
|
||||
manifests[key] = set(files.keys())
|
||||
|
||||
for first, second in _multi_install_safe_pairs():
|
||||
overlap = manifests[first] & manifests[second]
|
||||
assert not overlap, (
|
||||
f"{first} and {second} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(overlap)}"
|
||||
initial_files = set(initial_manifest.get("files", {}))
|
||||
additional_files = set(additional_manifest.get("files", {}))
|
||||
|
||||
assert initial_files.isdisjoint(additional_files), (
|
||||
f"{initial} and {additional} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(initial_files & additional_files)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -845,22 +845,6 @@ class TestRedirectStripping:
|
||||
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
|
||||
assert auth3 == "Bearer tok"
|
||||
|
||||
def test_malformed_redirect_url_raises_urlerror_not_valueerror(self):
|
||||
"""A redirect to a malformed URL (unterminated IPv6 bracket) surfaces
|
||||
as URLError, which download paths already handle, rather than an
|
||||
unhandled ValueError traceback."""
|
||||
import urllib.error
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo")
|
||||
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://[::1/asset")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fetch_latest_release_tag delegation
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
"""Parity tests for the Python check-prerequisites PoC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
|
||||
CHECK_PREREQS_PY = PROJECT_ROOT / "scripts" / "python" / "check_prerequisites.py"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_WINDOWS_POWERSHELL = (
|
||||
shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
) if os.name == "nt" else None
|
||||
|
||||
|
||||
def _install_scripts(repo: Path) -> None:
|
||||
bash_dir = repo / ".specify" / "scripts" / "bash"
|
||||
bash_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, bash_dir / "common.sh")
|
||||
shutil.copy(CHECK_PREREQS_SH, bash_dir / "check-prerequisites.sh")
|
||||
|
||||
ps_dir = repo / ".specify" / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, ps_dir / "common.ps1")
|
||||
shutil.copy(CHECK_PREREQS_PS, ps_dir / "check-prerequisites.ps1")
|
||||
|
||||
py_dir = repo / ".specify" / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PY, py_dir / "common.py")
|
||||
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def _git_init(repo: Path) -> None:
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
|
||||
)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prereq_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_scripts(repo)
|
||||
return repo
|
||||
|
||||
|
||||
def _py_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "python" / "check_prerequisites.py"
|
||||
return [sys.executable, str(script), *args]
|
||||
|
||||
|
||||
def _repo_copy_py_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / "scripts" / "python" / "check_prerequisites.py"
|
||||
return [sys.executable, str(script), *args]
|
||||
|
||||
|
||||
def _bash_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
return ["bash", str(script), *args]
|
||||
|
||||
|
||||
def _ps_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
return [exe, "-NoProfile", "-File", str(script), *args]
|
||||
|
||||
|
||||
def _run(
|
||||
cmd: list[str], repo: Path, env: dict[str, str] | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env or _clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _normalize_status_text(text: str) -> str:
|
||||
return (
|
||||
text.replace(" ✓ ", " [OK] ")
|
||||
.replace(" ✗ ", " [FAIL] ")
|
||||
.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
|
||||
def _normalize_help_text(text: str) -> str:
|
||||
normalized = text.replace("\r\n", "\n").replace(
|
||||
"check-prerequisites.sh", "check_prerequisites.py"
|
||||
)
|
||||
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("--json",),
|
||||
("--json", "--include-tasks"),
|
||||
("--json", "--require-tasks", "--include-tasks"),
|
||||
("--json", "--paths-only"),
|
||||
],
|
||||
)
|
||||
def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...]) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feat / "contracts" / "v1").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, *args), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, *args), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _json_stdout(py) == _json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "contracts").mkdir()
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _normalize_help_text(py.stdout) == _normalize_help_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_unknown_option_matches_bash_error_shape(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--bogus"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--bogus"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.stderr == bash.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("py_args", "ps_args"),
|
||||
[
|
||||
(("--json",), ("-Json",)),
|
||||
(("--json", "--include-tasks"), ("-Json", "-IncludeTasks")),
|
||||
(
|
||||
("--json", "--require-tasks", "--include-tasks"),
|
||||
("-Json", "-RequireTasks", "-IncludeTasks"),
|
||||
),
|
||||
(("--json", "--paths-only"), ("-Json", "-PathsOnly")),
|
||||
],
|
||||
ids=[
|
||||
"json",
|
||||
"json_include_tasks",
|
||||
"json_require_tasks_include_tasks",
|
||||
"json_paths_only",
|
||||
],
|
||||
)
|
||||
def test_python_json_output_matches_powershell(
|
||||
prereq_repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
|
||||
) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feat / "contracts" / "v1").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
ps = _run(_ps_cmd(prereq_repo, *ps_args), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, *py_args), prereq_repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert py.stderr == ps.stderr == ""
|
||||
assert _json_stdout(py) == _json_stdout(ps)
|
||||
|
||||
|
||||
def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "proj"
|
||||
outside = tmp_path / "outside"
|
||||
repo.mkdir()
|
||||
outside.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_write_feature_json(repo)
|
||||
(repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
|
||||
py_dir = repo / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True)
|
||||
shutil.copy(COMMON_PY, py_dir / "common.py")
|
||||
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
||||
|
||||
py = _run(_repo_copy_py_cmd(repo, "--json", "--paths-only"), outside)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert Path(_json_stdout(py)["REPO_ROOT"]) == repo
|
||||
|
||||
|
||||
def test_python_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
(prereq_repo / "specs" / "002-other").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
feature_json = prereq_repo / ".specify" / "feature.json"
|
||||
before = feature_json.read_text(encoding="utf-8")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo, env=env)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert "002-other" in _json_stdout(py)["FEATURE_DIR"]
|
||||
assert feature_json.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
feat = prereq_repo / "specs" / "002-other"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
data = json.loads(
|
||||
(prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert data["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "expected"),
|
||||
[
|
||||
(("--json",), "Feature directory not found"),
|
||||
(("--json",), "plan.md not found"),
|
||||
(("--json", "--require-tasks"), "tasks.md not found"),
|
||||
],
|
||||
ids=["missing_feature_context", "missing_plan", "missing_tasks"],
|
||||
)
|
||||
def test_python_negative_errors_are_stderr_only(
|
||||
tmp_path: Path, args: tuple[str, ...], expected: str
|
||||
) -> None:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_scripts(repo)
|
||||
|
||||
if expected in {"plan.md not found", "tasks.md not found"}:
|
||||
feat = repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
_write_feature_json(repo)
|
||||
if expected == "tasks.md not found":
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
py = _run(_py_cmd(repo, *args), repo)
|
||||
|
||||
assert py.returncode != 0
|
||||
assert expected in py.stderr
|
||||
assert expected not in py.stdout
|
||||
assert py.stdout.strip() == ""
|
||||
|
||||
|
||||
def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
|
||||
@@ -850,67 +850,6 @@ class TestExtensionSkillRegistration:
|
||||
assert ".specify/templates/checklist.md" in content
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
|
||||
def test_skill_registration_uses_extension_local_script_paths(self, project_dir, temp_dir):
|
||||
"""Auto-registered skills should not rewrite extension scripts into core scripts."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
ext_dir = temp_dir / "scripted-ext"
|
||||
ext_dir.mkdir()
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "scripted-ext",
|
||||
"name": "Scripted Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": "speckit.scripted-ext.check",
|
||||
"file": "commands/check.md",
|
||||
"description": "Scripted check command",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(ext_dir / "extension.yml", "w") as f:
|
||||
yaml.safe_dump(manifest_data, f)
|
||||
|
||||
(ext_dir / "commands").mkdir()
|
||||
(ext_dir / "scripts" / "bash").mkdir(parents=True)
|
||||
(ext_dir / "scripts" / "bash" / "resolve-skill.sh").write_text(
|
||||
"#!/usr/bin/env bash\n"
|
||||
)
|
||||
(ext_dir / "scripts" / "bash" / "ensure-skills.sh").write_text(
|
||||
"#!/usr/bin/env bash\n"
|
||||
)
|
||||
(ext_dir / "commands" / "check.md").write_text(
|
||||
"---\n"
|
||||
"description: Scripted check command\n"
|
||||
"scripts:\n"
|
||||
' sh: scripts/bash/resolve-skill.sh "{ARGS}"\n'
|
||||
"---\n\n"
|
||||
"Run {SCRIPT}\n"
|
||||
"Then run scripts/bash/ensure-skills.sh.\n"
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
content = (skills_dir / "speckit-scripted-ext-check" / "SKILL.md").read_text()
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert (
|
||||
'.specify/extensions/scripted-ext/scripts/bash/resolve-skill.sh "$ARGUMENTS"'
|
||||
in content
|
||||
)
|
||||
assert ".specify/extensions/scripted-ext/scripts/bash/ensure-skills.sh" in content
|
||||
assert ".specify/scripts/bash/resolve-skill.sh" not in content
|
||||
assert ".specify/scripts/bash/ensure-skills.sh" not in content
|
||||
|
||||
def test_missing_command_file_skipped(self, skills_project, temp_dir):
|
||||
"""Commands with missing source files should be skipped gracefully."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
@@ -31,7 +31,6 @@ from specify_cli.extensions import (
|
||||
ExtensionRegistry,
|
||||
ExtensionManager,
|
||||
CommandRegistrar,
|
||||
ConfigManager,
|
||||
HookExecutor,
|
||||
ExtensionCatalog,
|
||||
ExtensionError,
|
||||
@@ -1147,12 +1146,10 @@ class TestExtensionManager:
|
||||
context_note=None,
|
||||
link_outputs=False,
|
||||
create_missing_active_skills_dir=False,
|
||||
extension_id=None,
|
||||
):
|
||||
captured["create_missing_active_skills_dir"] = (
|
||||
create_missing_active_skills_dir
|
||||
)
|
||||
captured["extension_id"] = extension_id
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
@@ -1166,7 +1163,6 @@ class TestExtensionManager:
|
||||
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
|
||||
|
||||
assert captured["create_missing_active_skills_dir"] is False
|
||||
assert captured["extension_id"] == manifest.id
|
||||
|
||||
def test_install_duplicate(self, extension_dir, project_dir):
|
||||
"""Test installing already installed extension."""
|
||||
@@ -1698,29 +1694,6 @@ $ARGUMENTS
|
||||
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
|
||||
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {ARGS}"
|
||||
|
||||
def test_adjust_script_paths_rewrites_extension_top_level_scripts(self):
|
||||
"""Extension command-local scripts should resolve under the installed extension."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
original = {
|
||||
"scripts": {
|
||||
"sh": "scripts/bash/resolve-skill.sh {ARGS}",
|
||||
"ps": "../../scripts/powershell/setup-plan.ps1 -Json",
|
||||
}
|
||||
}
|
||||
|
||||
adjusted = registrar._adjust_script_paths(original, extension_id="test-ext")
|
||||
|
||||
assert (
|
||||
adjusted["scripts"]["sh"]
|
||||
== ".specify/extensions/test-ext/scripts/bash/resolve-skill.sh {ARGS}"
|
||||
)
|
||||
assert (
|
||||
adjusted["scripts"]["ps"]
|
||||
== ".specify/scripts/powershell/setup-plan.ps1 -Json"
|
||||
)
|
||||
|
||||
def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
|
||||
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
@@ -1735,24 +1708,6 @@ $ARGUMENTS
|
||||
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
|
||||
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
|
||||
|
||||
def test_rewrite_project_relative_paths_uses_extension_context_for_scripts(self):
|
||||
"""Extension source bodies treat top-level scripts/ as extension-local."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
body = (
|
||||
"Run scripts/bash/ensure-skills.sh\n"
|
||||
"Fallback ../../scripts/bash/setup-plan.sh\n"
|
||||
"Read templates/checklist.md\n"
|
||||
)
|
||||
|
||||
rewritten = AgentCommandRegistrar.rewrite_project_relative_paths(
|
||||
body, extension_id="test-ext"
|
||||
)
|
||||
|
||||
assert ".specify/extensions/test-ext/scripts/bash/ensure-skills.sh" in rewritten
|
||||
assert ".specify/scripts/bash/setup-plan.sh" in rewritten
|
||||
assert ".specify/templates/checklist.md" in rewritten
|
||||
|
||||
def test_render_toml_command_handles_embedded_triple_double_quotes(self):
|
||||
"""TOML renderer should stay valid when body includes triple double-quotes."""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
@@ -5441,29 +5396,6 @@ class TestExtensionAddCLI:
|
||||
f"confirm must precede spinner, got: {call_order}"
|
||||
assert result.exit_code == 0 # user declined → clean exit
|
||||
|
||||
def test_add_from_malformed_ipv6_url_exits_cleanly(self, tmp_path):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
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):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://[::1/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Invalid URL" in plain
|
||||
|
||||
def test_add_status_escapes_extension_markup(self, tmp_path):
|
||||
"""User-controlled extension names must not be parsed as Rich markup."""
|
||||
from rich.markup import escape as escape_markup
|
||||
@@ -7560,52 +7492,3 @@ def test_extension_wrapper_resolves_ghes_asset_when_host_configured(tmp_path, mo
|
||||
)
|
||||
assert resolved == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
assert captured == ["https://ghes.example/api/v3/repos/o/r/releases/tags/v1"]
|
||||
|
||||
|
||||
class TestConfigManagerNonMappingYaml:
|
||||
"""A non-mapping YAML config root must not crash config/hook resolution."""
|
||||
|
||||
def _make(self, tmp_path, body: str):
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "jira-config.yml").write_text(body, encoding="utf-8")
|
||||
return ConfigManager(tmp_path, "jira")
|
||||
|
||||
def test_get_config_coerces_list_root(self, tmp_path):
|
||||
"""A YAML list root previously raised AttributeError in _merge_configs."""
|
||||
cm = self._make(tmp_path, "- foo\n- bar\n")
|
||||
assert cm.get_config() == {}
|
||||
|
||||
def test_get_config_coerces_scalar_root(self, tmp_path):
|
||||
cm = self._make(tmp_path, "just a string\n")
|
||||
assert cm.get_config() == {}
|
||||
|
||||
def test_has_value_and_get_value_do_not_raise(self, tmp_path):
|
||||
cm = self._make(tmp_path, "- foo\n")
|
||||
assert cm.has_value("anything") is False
|
||||
assert cm.get_value("anything") is None
|
||||
|
||||
def test_valid_local_config_layers_over_list_root_project_config(self, tmp_path):
|
||||
"""A malformed project config must not block a valid local config."""
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "jira-config.yml").write_text("- foo\n- bar\n", encoding="utf-8")
|
||||
(ext_dir / "local-config.yml").write_text(
|
||||
"notifications:\n enabled: true\n", encoding="utf-8"
|
||||
)
|
||||
cm = ConfigManager(tmp_path, "jira")
|
||||
assert cm.get_value("notifications.enabled") is True
|
||||
|
||||
def test_hook_condition_returns_false_without_raising(self, tmp_path):
|
||||
"""`config.x is set` on a scalar-root config must evaluate cleanly.
|
||||
|
||||
Before the fix, _merge_configs raised AttributeError and the
|
||||
exception was swallowed by should_execute_hook, silently disabling
|
||||
every config-based hook for the extension. Assert on
|
||||
_evaluate_condition directly so the crash isn't masked.
|
||||
"""
|
||||
ext_dir = tmp_path / ".specify" / "extensions" / "jira"
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "jira-config.yml").write_text("just a string\n", encoding="utf-8")
|
||||
executor = HookExecutor(tmp_path)
|
||||
assert executor._evaluate_condition("config.x is set", "jira") is False
|
||||
|
||||
@@ -233,23 +233,6 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_returns_none_on_malformed_ghes_port(self):
|
||||
"""A malformed port on an allowlisted GHES host returns None, not a
|
||||
ValueError (contract: resolve or return None, never raise)."""
|
||||
called = []
|
||||
|
||||
def open_never(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
raise AssertionError("open_url_fn must not be called")
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example:notaport/o/r/releases/download/v1/ext.zip",
|
||||
open_never,
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
|
||||
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
||||
not allowlisted: passthrough issues no API request, and the download
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
"""Tests for post_process_command_content() hook on IntegrationBase.
|
||||
|
||||
Verifies that the generalized post-processing hook:
|
||||
- Runs for non-skills format types (Markdown, TOML, YAML)
|
||||
- Does NOT run for skills-format agents
|
||||
- Default no-op returns content unchanged
|
||||
- Exceptions propagate to caller
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registrar():
|
||||
return CommandRegistrar()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ext_dir(tmp_path):
|
||||
"""Create a mock extension with a simple command template."""
|
||||
ext = tmp_path / "extension"
|
||||
ext.mkdir()
|
||||
cmd_dir = ext / "commands"
|
||||
cmd_dir.mkdir()
|
||||
return ext, cmd_dir
|
||||
|
||||
|
||||
def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"):
|
||||
cmd_file = cmd_dir / name
|
||||
cmd_file.write_text(
|
||||
f"---\ndescription: Test command\n---\n\n{body}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return cmd_file
|
||||
|
||||
|
||||
class TestDefaultNoOp:
|
||||
def test_returns_content_unchanged(self):
|
||||
base = IntegrationBase()
|
||||
content = "Some command content\nwith multiple lines."
|
||||
assert base.post_process_command_content(content) == content
|
||||
|
||||
def test_empty_string(self):
|
||||
base = IntegrationBase()
|
||||
assert base.post_process_command_content("") == ""
|
||||
|
||||
|
||||
class TestMarkdownAgentPostProcess:
|
||||
def test_opencode_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
opencode = get_integration("opencode")
|
||||
marker = "<!-- POST_PROCESSED -->"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + marker
|
||||
|
||||
monkeypatch.setattr(
|
||||
opencode.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"opencode", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestTomlAgentPostProcess:
|
||||
def test_gemini_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
gemini = get_integration("gemini")
|
||||
marker = "# POST_PROCESSED"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + f"\n{marker}\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
gemini.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"gemini", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestYamlAgentPostProcess:
|
||||
def test_goose_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
goose = get_integration("goose")
|
||||
marker = "# POST_PROCESSED"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + f"\n{marker}\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
goose.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"goose", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestSkillsAgentExcluded:
|
||||
def test_claude_post_process_not_called(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
claude = get_integration("claude")
|
||||
marker = "<!-- SHOULD_NOT_APPEAR -->"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + marker
|
||||
|
||||
monkeypatch.setattr(
|
||||
claude.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"claude", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
skill_file = (
|
||||
tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md"
|
||||
)
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert marker not in content
|
||||
|
||||
def test_skills_agent_method_never_called(
|
||||
self, tmp_path, registrar, ext_dir
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
claude = get_integration("claude")
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
|
||||
with patch.object(
|
||||
claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content
|
||||
) as mock_method:
|
||||
registrar.register_commands(
|
||||
"claude", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
mock_method.assert_not_called()
|
||||
|
||||
|
||||
class TestExceptionPropagation:
|
||||
def test_hook_exception_propagates(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
opencode = get_integration("opencode")
|
||||
|
||||
def _raise(self, content):
|
||||
raise RuntimeError("Hook failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
opencode.__class__, "post_process_command_content", _raise
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
with pytest.raises(RuntimeError, match="Hook failed"):
|
||||
registrar.register_commands(
|
||||
"opencode", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
|
||||
class TestRegressionPlainTemplate:
|
||||
@pytest.mark.parametrize(
|
||||
"agent,path_pattern",
|
||||
[
|
||||
("claude", ".claude/skills/speckit-test-plain/SKILL.md"),
|
||||
("opencode", ".opencode/commands/speckit.test.plain.md"),
|
||||
],
|
||||
ids=["skills", "markdown"],
|
||||
)
|
||||
def test_plain_template_unchanged(
|
||||
self, tmp_path, registrar, ext_dir, agent, path_pattern
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
body_text = "This is a plain command with no special content.\n"
|
||||
_write_cmd(cmd_dir, name="plain.md", body=body_text)
|
||||
|
||||
commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}]
|
||||
registrar.register_commands(
|
||||
agent, commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
output_file = tmp_path / path_pattern
|
||||
assert output_file.exists(), f"Output file missing for {agent}"
|
||||
content = output_file.read_text(encoding="utf-8")
|
||||
assert body_text.strip() in content, f"Body text missing in {agent} output"
|
||||
@@ -4538,27 +4538,6 @@ class TestBundledPresetLocator:
|
||||
assert "got https://" not in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url") as open_url:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["preset", "add", "--from", "https://[::1/preset.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid URL" in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
|
||||
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
|
||||
import typer
|
||||
|
||||
@@ -322,87 +322,3 @@ class TestWorkflowRunWithoutProject:
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert ".specify path exists but is not a directory" in result.output
|
||||
|
||||
|
||||
class TestWorkflowRunJsonErrorStream:
|
||||
"""Under --json, error text must go to stderr so stdout stays parseable."""
|
||||
|
||||
def _bad_workflow(self, tmp_path):
|
||||
wf = tmp_path / "bad.yml"
|
||||
wf.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "bad-wf",
|
||||
"name": "Bad",
|
||||
"version": "1.0.0",
|
||||
"description": "fails validation",
|
||||
},
|
||||
# shell step missing required 'run' -> validation error
|
||||
"steps": [{"id": "s", "type": "shell"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return wf
|
||||
|
||||
def test_run_json_validation_error_not_on_stdout(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
wf = self._bad_workflow(tmp_path)
|
||||
runner = CliRunner()
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(
|
||||
app, ["workflow", "run", str(wf), "--json"], catch_exceptions=False
|
||||
)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
assert result.exit_code == 1
|
||||
# stdout must carry only JSON (here: nothing) — never human error text.
|
||||
assert "validation failed" not in result.stdout
|
||||
assert "Error" not in result.stdout
|
||||
# The message is routed to stderr instead.
|
||||
assert "validation failed" in result.stderr
|
||||
|
||||
def test_run_json_invalid_input_not_on_stdout(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
# A valid single-shell workflow so we get past load/validate to
|
||||
# _parse_input_values, which rejects the malformed --input.
|
||||
wf = tmp_path / "ok.yml"
|
||||
wf.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": "ok-wf",
|
||||
"name": "OK",
|
||||
"version": "1.0.0",
|
||||
"description": "x",
|
||||
},
|
||||
"steps": [{"id": "s", "type": "shell", "run": "echo hi"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(tmp_path)
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["workflow", "run", str(wf), "--json", "--input", "no-equals"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid input format" not in result.stdout
|
||||
assert "Invalid input format" in result.stderr
|
||||
|
||||
@@ -1266,26 +1266,6 @@ class TestShellStep:
|
||||
errors = step.validate({"id": "test"})
|
||||
assert any("missing 'run'" in e for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("bad_run", [None, ["echo", "hi"], 42])
|
||||
def test_validate_rejects_non_string_run(self, bad_run):
|
||||
"""A non-string 'run' must be rejected at validation.
|
||||
|
||||
execute() str()-coerces run and invokes it under shell=True, so a
|
||||
null or list run would otherwise run the Python repr as a command.
|
||||
"""
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
step = ShellStep()
|
||||
errors = step.validate({"id": "s", "run": bad_run})
|
||||
assert any("'run' must be a string" in e for e in errors)
|
||||
|
||||
def test_validate_accepts_string_and_expression_run(self):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
|
||||
step = ShellStep()
|
||||
assert step.validate({"id": "s", "run": "echo hi"}) == []
|
||||
assert step.validate({"id": "s", "run": "{{ steps.x.output }}"}) == []
|
||||
|
||||
|
||||
def test_output_format_json_exposes_data(self, tmp_path):
|
||||
from specify_cli.workflows.steps.shell import ShellStep
|
||||
@@ -2202,27 +2182,6 @@ class TestFanInStep:
|
||||
errors = step.validate({"id": "test", "wait_for": "not-a-list"})
|
||||
assert any("non-empty list" in e for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("bad_output", [["{{ fan_in.results }}"], "{{ x }}", 42])
|
||||
def test_validate_rejects_non_mapping_output(self, bad_output):
|
||||
"""A non-mapping 'output' must be rejected: execute() would otherwise
|
||||
silently coerce it to {} and drop the declared aggregation keys."""
|
||||
from specify_cli.workflows.steps.fan_in import FanInStep
|
||||
|
||||
step = FanInStep()
|
||||
errors = step.validate(
|
||||
{"id": "j", "wait_for": ["a"], "output": bad_output}
|
||||
)
|
||||
assert any("'output' must be a mapping" in e for e in errors)
|
||||
|
||||
def test_validate_accepts_mapping_or_absent_output(self):
|
||||
from specify_cli.workflows.steps.fan_in import FanInStep
|
||||
|
||||
step = FanInStep()
|
||||
assert step.validate(
|
||||
{"id": "j", "wait_for": ["a"], "output": {"joined": "{{ x }}"}}
|
||||
) == []
|
||||
assert step.validate({"id": "j", "wait_for": ["a"]}) == []
|
||||
|
||||
|
||||
class TestFanOutConcurrency:
|
||||
"""Fan-out honors max_concurrency (WorkflowEngine._run_fan_out)."""
|
||||
@@ -5593,23 +5552,6 @@ class TestWorkflowRemoveGuard:
|
||||
|
||||
|
||||
class TestWorkflowAddSymlinkGuard:
|
||||
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify").mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["workflow", "add", "https://[::1/wf.yaml"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert "Invalid URL" in result.output
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
|
||||
def test_add_refuses_symlinked_specify(self, temp_dir, monkeypatch):
|
||||
"""workflow add must refuse a symlinked .specify (writes could escape root)."""
|
||||
|
||||
@@ -222,39 +222,3 @@ def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch):
|
||||
# A relative path containing ':' but no '://' is still a local path.
|
||||
source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50)
|
||||
assert source.url.endswith("weird:name.json") or "weird" in source.url
|
||||
|
||||
|
||||
def test_add_source_rejects_plain_http_for_non_localhost(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="HTTPS"):
|
||||
cc.add_source(project, "http://example.com/catalog.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_allows_http_for_localhost(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
source = cc.add_source(project, "http://localhost:8080/c.json", policy="install-allowed", priority=50)
|
||||
assert source.url == "http://localhost:8080/c.json"
|
||||
|
||||
|
||||
def test_add_source_rejects_host_less_remote_urls(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
for url in ("https://:8080", "https://user@"):
|
||||
with pytest.raises(BundlerError, match="host"):
|
||||
cc.add_source(project, url, policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="Invalid catalog url"):
|
||||
cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_remove_source_does_not_crash_on_invalid_ipv6(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="No project-scoped catalog source"):
|
||||
cc.remove_source(project, "https://[::1/c.json")
|
||||
|
||||
Reference in New Issue
Block a user