mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 15:06:10 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e08236f0b4 | ||
|
|
2b5e175440 | ||
|
|
0e40438903 | ||
|
|
1930f89d17 | ||
|
|
4bb5166445 | ||
|
|
73f77c200f | ||
|
|
b8d27e472f | ||
|
|
587b1859fa | ||
|
|
52480ee50f | ||
|
|
d3e7b06fa7 | ||
|
|
de6a04eaad |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
<!-- insert new changelog below this comment -->
|
<!-- insert new changelog below this comment -->
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- fix(workflows): match gate reject option case-insensitively (#3335)
|
||||||
|
- fix(bundler): reject host-less catalog URLs in adapters (use hostname, not netloc) (#3333)
|
||||||
|
- fix(bundler): resolve catalog search at highest-precedence source before filtering (#3331)
|
||||||
|
- fix(workflows): compare non-numeric strings lexicographically instead of returning False (#3323)
|
||||||
|
- fix(workflows): quote-aware interpolation so a literal }} in a filter arg doesn't break multi-expression templates (#3307)
|
||||||
|
- Support namespaced git feature branch templates (#3293)
|
||||||
|
- chore(deps): bump actions/setup-dotnet from 5.3.0 to 5.4.0 (#3315)
|
||||||
|
- fix(integrations): cursor-agent honors executable/extra-args env overrides (#3265)
|
||||||
|
- docs: drop stale kimi KIMI.md->AGENTS.md migration note (#3291)
|
||||||
|
- chore: release 0.12.4, begin 0.12.5.dev0 development (#3305)
|
||||||
|
|
||||||
## [0.12.4] - 2026-07-02
|
## [0.12.4] - 2026-07-02
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"updated_at": "2026-07-01T00:00:00Z",
|
"updated_at": "2026-07-06T00:00:00Z",
|
||||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||||
"extensions": {
|
"extensions": {
|
||||||
"aide": {
|
"aide": {
|
||||||
@@ -670,6 +670,40 @@
|
|||||||
"created_at": "2026-04-11T18:00:00Z",
|
"created_at": "2026-04-11T18:00:00Z",
|
||||||
"updated_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": {
|
"ci-guard": {
|
||||||
"name": "CI Guard",
|
"name": "CI Guard",
|
||||||
"id": "ci-guard",
|
"id": "ci-guard",
|
||||||
@@ -2679,8 +2713,8 @@
|
|||||||
"id": "ralph",
|
"id": "ralph",
|
||||||
"description": "Autonomous implementation loop using AI agent CLI",
|
"description": "Autonomous implementation loop using AI agent CLI",
|
||||||
"author": "Rubiss",
|
"author": "Rubiss",
|
||||||
"version": "1.1.1",
|
"version": "1.2.1",
|
||||||
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.1.1.zip",
|
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.2.1.zip",
|
||||||
"repository": "https://github.com/Rubiss-Projects/spec-kit-ralph",
|
"repository": "https://github.com/Rubiss-Projects/spec-kit-ralph",
|
||||||
"homepage": "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",
|
"documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md",
|
||||||
@@ -2689,7 +2723,7 @@
|
|||||||
"category": "code",
|
"category": "code",
|
||||||
"effect": "read-write",
|
"effect": "read-write",
|
||||||
"requires": {
|
"requires": {
|
||||||
"speckit_version": ">=0.1.0",
|
"speckit_version": ">=0.8.5",
|
||||||
"tools": [
|
"tools": [
|
||||||
{
|
{
|
||||||
"name": "copilot",
|
"name": "copilot",
|
||||||
@@ -2699,6 +2733,10 @@
|
|||||||
"name": "codex",
|
"name": "codex",
|
||||||
"required": false
|
"required": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "claude",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "git",
|
"name": "git",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -2714,13 +2752,14 @@
|
|||||||
"automation",
|
"automation",
|
||||||
"loop",
|
"loop",
|
||||||
"copilot",
|
"copilot",
|
||||||
"codex"
|
"codex",
|
||||||
|
"claude"
|
||||||
],
|
],
|
||||||
"verified": false,
|
"verified": false,
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"stars": 0,
|
"stars": 0,
|
||||||
"created_at": "2026-03-09T00:00:00Z",
|
"created_at": "2026-03-09T00:00:00Z",
|
||||||
"updated_at": "2026-06-05T03:11:06Z"
|
"updated_at": "2026-07-06T00:00:00Z"
|
||||||
},
|
},
|
||||||
"reconcile": {
|
"reconcile": {
|
||||||
"name": "Reconcile Extension",
|
"name": "Reconcile Extension",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.12.5.dev0"
|
version = "0.12.6"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
207
scripts/python/check_prerequisites.py
Normal file
207
scripts/python/check_prerequisites.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
#!/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())
|
||||||
210
scripts/python/common.py
Normal file
210
scripts/python/common.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""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}"
|
||||||
@@ -148,7 +148,9 @@ class CommandRegistrar:
|
|||||||
)
|
)
|
||||||
return f"---\n{yaml_str}---\n"
|
return f"---\n{yaml_str}---\n"
|
||||||
|
|
||||||
def _adjust_script_paths(self, frontmatter: dict) -> dict:
|
def _adjust_script_paths(
|
||||||
|
self, frontmatter: dict, extension_id: Optional[str] = None
|
||||||
|
) -> dict:
|
||||||
"""Normalize script paths in frontmatter to generated project locations.
|
"""Normalize script paths in frontmatter to generated project locations.
|
||||||
|
|
||||||
Rewrites known repo-relative and top-level script paths under the
|
Rewrites known repo-relative and top-level script paths under the
|
||||||
@@ -158,6 +160,7 @@ class CommandRegistrar:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
frontmatter: Frontmatter dictionary
|
frontmatter: Frontmatter dictionary
|
||||||
|
extension_id: Extension id when rendering extension-owned commands.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Modified frontmatter with normalized project paths
|
Modified frontmatter with normalized project paths
|
||||||
@@ -168,11 +171,15 @@ class CommandRegistrar:
|
|||||||
if isinstance(scripts, dict):
|
if isinstance(scripts, dict):
|
||||||
for key, script_path in scripts.items():
|
for key, script_path in scripts.items():
|
||||||
if isinstance(script_path, str):
|
if isinstance(script_path, str):
|
||||||
scripts[key] = self.rewrite_project_relative_paths(script_path)
|
scripts[key] = self.rewrite_project_relative_paths(
|
||||||
|
script_path, extension_id=extension_id
|
||||||
|
)
|
||||||
return frontmatter
|
return frontmatter
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def rewrite_project_relative_paths(text: str) -> str:
|
def rewrite_project_relative_paths(
|
||||||
|
text: str, extension_id: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
"""Rewrite repo-relative paths to their generated project locations."""
|
"""Rewrite repo-relative paths to their generated project locations."""
|
||||||
if not isinstance(text, str) or not text:
|
if not isinstance(text, str) or not text:
|
||||||
return text
|
return text
|
||||||
@@ -184,10 +191,18 @@ class CommandRegistrar:
|
|||||||
):
|
):
|
||||||
text = text.replace(old, new)
|
text = text.replace(old, new)
|
||||||
|
|
||||||
# Only rewrite top-level style references so extension-local paths like
|
# Only rewrite top-level style references so existing generated paths
|
||||||
# ".specify/extensions/<ext>/scripts/..." remain intact.
|
# 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/"
|
||||||
|
)
|
||||||
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
|
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?memory/', r"\1.specify/memory/", text)
|
||||||
text = re.sub(r'(^|[\s`"\'(])(?:\.?/)?scripts/', r"\1.specify/scripts/", text)
|
text = re.sub(
|
||||||
|
r'(^|[\s`"\'(])(?:\.?/)?scripts/', rf"\1{scripts_replacement}", text
|
||||||
|
)
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
|
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
|
||||||
)
|
)
|
||||||
@@ -312,6 +327,7 @@ class CommandRegistrar:
|
|||||||
source_id: str,
|
source_id: str,
|
||||||
source_file: str,
|
source_file: str,
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
|
extension_id: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a command override as a SKILL.md file.
|
"""Render a command override as a SKILL.md file.
|
||||||
|
|
||||||
@@ -331,7 +347,7 @@ class CommandRegistrar:
|
|||||||
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
|
agent_config = self.AGENT_CONFIGS.get(agent_name, {})
|
||||||
if agent_config.get("extension") == "/SKILL.md":
|
if agent_config.get("extension") == "/SKILL.md":
|
||||||
body = self.resolve_skill_placeholders(
|
body = self.resolve_skill_placeholders(
|
||||||
agent_name, frontmatter, body, project_root
|
agent_name, frontmatter, body, project_root, extension_id=extension_id
|
||||||
)
|
)
|
||||||
|
|
||||||
description = frontmatter.get(
|
description = frontmatter.get(
|
||||||
@@ -393,7 +409,11 @@ class CommandRegistrar:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_skill_placeholders(
|
def resolve_skill_placeholders(
|
||||||
agent_name: str, frontmatter: dict, body: str, project_root: Path
|
agent_name: str,
|
||||||
|
frontmatter: dict,
|
||||||
|
body: str,
|
||||||
|
project_root: Path,
|
||||||
|
extension_id: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Resolve script placeholders for skills-backed agents."""
|
"""Resolve script placeholders for skills-backed agents."""
|
||||||
if not isinstance(frontmatter, dict):
|
if not isinstance(frontmatter, dict):
|
||||||
@@ -433,7 +453,9 @@ class CommandRegistrar:
|
|||||||
|
|
||||||
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
|
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
|
||||||
|
|
||||||
return CommandRegistrar.rewrite_project_relative_paths(body)
|
return CommandRegistrar.rewrite_project_relative_paths(
|
||||||
|
body, extension_id=extension_id
|
||||||
|
)
|
||||||
|
|
||||||
def _convert_argument_placeholder(
|
def _convert_argument_placeholder(
|
||||||
self, content: str, from_placeholder: str, to_placeholder: str
|
self, content: str, from_placeholder: str, to_placeholder: str
|
||||||
@@ -528,6 +550,7 @@ class CommandRegistrar:
|
|||||||
context_note: str = None,
|
context_note: str = None,
|
||||||
_resolved_dir: Path = None,
|
_resolved_dir: Path = None,
|
||||||
link_outputs: bool = False,
|
link_outputs: bool = False,
|
||||||
|
extension_id: Optional[str] = None,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Register commands for a specific agent.
|
"""Register commands for a specific agent.
|
||||||
|
|
||||||
@@ -545,6 +568,7 @@ class CommandRegistrar:
|
|||||||
link_outputs: If True, write rendered output to a source-local
|
link_outputs: If True, write rendered output to a source-local
|
||||||
dev cache and symlink the agent command file to it. Falls back
|
dev cache and symlink the agent command file to it. Falls back
|
||||||
to a normal file write when symlinks are unavailable.
|
to a normal file write when symlinks are unavailable.
|
||||||
|
extension_id: Extension id when rendering extension-owned commands.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of registered command names
|
List of registered command names
|
||||||
@@ -614,7 +638,9 @@ class CommandRegistrar:
|
|||||||
frontmatter[key] = core_frontmatter[key]
|
frontmatter[key] = core_frontmatter[key]
|
||||||
frontmatter.pop("strategy", None)
|
frontmatter.pop("strategy", None)
|
||||||
|
|
||||||
frontmatter = self._adjust_script_paths(frontmatter)
|
frontmatter = self._adjust_script_paths(
|
||||||
|
frontmatter, extension_id=extension_id
|
||||||
|
)
|
||||||
|
|
||||||
for key in agent_config.get("strip_frontmatter_keys", []):
|
for key in agent_config.get("strip_frontmatter_keys", []):
|
||||||
frontmatter.pop(key, None)
|
frontmatter.pop(key, None)
|
||||||
@@ -653,10 +679,11 @@ class CommandRegistrar:
|
|||||||
source_id,
|
source_id,
|
||||||
cmd_file,
|
cmd_file,
|
||||||
project_root,
|
project_root,
|
||||||
|
extension_id=extension_id,
|
||||||
)
|
)
|
||||||
elif agent_config["format"] == "markdown":
|
elif agent_config["format"] == "markdown":
|
||||||
body = self.resolve_skill_placeholders(
|
body = self.resolve_skill_placeholders(
|
||||||
agent_name, frontmatter, body, project_root
|
agent_name, frontmatter, body, project_root, extension_id=extension_id
|
||||||
)
|
)
|
||||||
body = self._convert_argument_placeholder(
|
body = self._convert_argument_placeholder(
|
||||||
body, "$ARGUMENTS", agent_config["args"]
|
body, "$ARGUMENTS", agent_config["args"]
|
||||||
@@ -666,7 +693,7 @@ class CommandRegistrar:
|
|||||||
)
|
)
|
||||||
elif agent_config["format"] == "toml":
|
elif agent_config["format"] == "toml":
|
||||||
body = self.resolve_skill_placeholders(
|
body = self.resolve_skill_placeholders(
|
||||||
agent_name, frontmatter, body, project_root
|
agent_name, frontmatter, body, project_root, extension_id=extension_id
|
||||||
)
|
)
|
||||||
body = self._convert_argument_placeholder(
|
body = self._convert_argument_placeholder(
|
||||||
body, "$ARGUMENTS", agent_config["args"]
|
body, "$ARGUMENTS", agent_config["args"]
|
||||||
@@ -721,6 +748,7 @@ class CommandRegistrar:
|
|||||||
source_id,
|
source_id,
|
||||||
cmd_file,
|
cmd_file,
|
||||||
project_root,
|
project_root,
|
||||||
|
extension_id=extension_id,
|
||||||
)
|
)
|
||||||
elif agent_config["format"] == "markdown":
|
elif agent_config["format"] == "markdown":
|
||||||
alias_output = self.render_markdown_command(
|
alias_output = self.render_markdown_command(
|
||||||
@@ -750,6 +778,7 @@ class CommandRegistrar:
|
|||||||
source_id,
|
source_id,
|
||||||
cmd_file,
|
cmd_file,
|
||||||
project_root,
|
project_root,
|
||||||
|
extension_id=extension_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
alias_file = (
|
alias_file = (
|
||||||
@@ -881,6 +910,7 @@ class CommandRegistrar:
|
|||||||
context_note: str = None,
|
context_note: str = None,
|
||||||
link_outputs: bool = False,
|
link_outputs: bool = False,
|
||||||
create_missing_active_skills_dir: bool = False,
|
create_missing_active_skills_dir: bool = False,
|
||||||
|
extension_id: Optional[str] = None,
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, List[str]]:
|
||||||
"""Register commands for all detected agents in the project.
|
"""Register commands for all detected agents in the project.
|
||||||
|
|
||||||
@@ -897,6 +927,7 @@ class CommandRegistrar:
|
|||||||
Recovery requires active skills mode (or Kimi's existing native
|
Recovery requires active skills mode (or Kimi's existing native
|
||||||
skills directory) and is skipped when safe resolution or
|
skills directory) and is skipped when safe resolution or
|
||||||
creation fails.
|
creation fails.
|
||||||
|
extension_id: Extension id when rendering extension-owned commands.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping agent names to list of registered commands
|
Dictionary mapping agent names to list of registered commands
|
||||||
@@ -999,6 +1030,7 @@ class CommandRegistrar:
|
|||||||
context_note=context_note,
|
context_note=context_note,
|
||||||
_resolved_dir=agent_dir,
|
_resolved_dir=agent_dir,
|
||||||
link_outputs=link_outputs,
|
link_outputs=link_outputs,
|
||||||
|
extension_id=extension_id,
|
||||||
)
|
)
|
||||||
if registered:
|
if registered:
|
||||||
results[agent_name] = registered
|
results[agent_name] = registered
|
||||||
@@ -1023,6 +1055,7 @@ class CommandRegistrar:
|
|||||||
project_root: Path,
|
project_root: Path,
|
||||||
context_note: Optional[str] = None,
|
context_note: Optional[str] = None,
|
||||||
link_outputs: bool = False,
|
link_outputs: bool = False,
|
||||||
|
extension_id: Optional[str] = None,
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, List[str]]:
|
||||||
"""Register commands for all non-skill agents in the project.
|
"""Register commands for all non-skill agents in the project.
|
||||||
|
|
||||||
@@ -1038,6 +1071,7 @@ class CommandRegistrar:
|
|||||||
context_note: Custom context comment for markdown output
|
context_note: Custom context comment for markdown output
|
||||||
link_outputs: If True, create dev-mode symlinks for rendered
|
link_outputs: If True, create dev-mode symlinks for rendered
|
||||||
command files when supported by the OS.
|
command files when supported by the OS.
|
||||||
|
extension_id: Extension id when rendering extension-owned commands.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping agent names to list of registered commands
|
Dictionary mapping agent names to list of registered commands
|
||||||
@@ -1066,6 +1100,7 @@ class CommandRegistrar:
|
|||||||
context_note=context_note,
|
context_note=context_note,
|
||||||
_resolved_dir=agent_dir,
|
_resolved_dir=agent_dir,
|
||||||
link_outputs=link_outputs,
|
link_outputs=link_outputs,
|
||||||
|
extension_id=extension_id,
|
||||||
)
|
)
|
||||||
if registered:
|
if registered:
|
||||||
results[agent_name] = registered
|
results[agent_name] = registered
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ def _is_local_path(url: str) -> bool:
|
|||||||
"""True when *url* denotes a local filesystem path rather than a URL."""
|
"""True when *url* denotes a local filesystem path rather than a URL."""
|
||||||
if _WINDOWS_DRIVE_RE.match(url):
|
if _WINDOWS_DRIVE_RE.match(url):
|
||||||
return True
|
return True
|
||||||
scheme = urlparse(url).scheme.lower()
|
try:
|
||||||
|
scheme = urlparse(url).scheme.lower()
|
||||||
|
except ValueError:
|
||||||
|
# Malformed URLs (e.g. an unclosed IPv6 bracket) are not local paths.
|
||||||
|
return False
|
||||||
return scheme not in _REMOTE_SCHEMES
|
return scheme not in _REMOTE_SCHEMES
|
||||||
|
|
||||||
|
|
||||||
@@ -137,7 +141,10 @@ def add_source(
|
|||||||
url = url.strip()
|
url = url.strip()
|
||||||
if not url:
|
if not url:
|
||||||
raise BundlerError("A catalog url is required.")
|
raise BundlerError("A catalog url is required.")
|
||||||
parsed = urlparse(url)
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
|
||||||
if not (parsed.scheme or parsed.path):
|
if not (parsed.scheme or parsed.path):
|
||||||
raise BundlerError(f"Invalid catalog url: '{url}'.")
|
raise BundlerError(f"Invalid catalog url: '{url}'.")
|
||||||
# Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are
|
# Reject unsupported URL schemes (e.g. ssh://, ftp://) up front so they are
|
||||||
@@ -148,6 +155,20 @@ def add_source(
|
|||||||
f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. "
|
f"Unsupported catalog url scheme '{parsed.scheme}://' in '{url}'. "
|
||||||
"Use http(s)://, file://, builtin://, or a local path."
|
"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)
|
url = _canonicalize_url(url)
|
||||||
install_policy = InstallPolicy.parse(policy)
|
install_policy = InstallPolicy.parse(policy)
|
||||||
|
|||||||
@@ -1075,9 +1075,11 @@ class ExtensionManager:
|
|||||||
pass # best-effort cleanup
|
pass # best-effort cleanup
|
||||||
continue
|
continue
|
||||||
frontmatter, body = registrar.parse_frontmatter(content)
|
frontmatter, body = registrar.parse_frontmatter(content)
|
||||||
frontmatter = registrar._adjust_script_paths(frontmatter)
|
frontmatter = registrar._adjust_script_paths(
|
||||||
|
frontmatter, extension_id=manifest.id
|
||||||
|
)
|
||||||
body = registrar.resolve_skill_placeholders(
|
body = registrar.resolve_skill_placeholders(
|
||||||
selected_ai, frontmatter, body, self.project_root
|
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
|
||||||
)
|
)
|
||||||
|
|
||||||
original_desc = frontmatter.get("description", "")
|
original_desc = frontmatter.get("description", "")
|
||||||
@@ -1958,6 +1960,7 @@ class CommandRegistrar:
|
|||||||
project_root,
|
project_root,
|
||||||
context_note=context_note,
|
context_note=context_note,
|
||||||
link_outputs=link_outputs,
|
link_outputs=link_outputs,
|
||||||
|
extension_id=manifest.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def register_commands_for_all_agents(
|
def register_commands_for_all_agents(
|
||||||
@@ -1978,6 +1981,7 @@ class CommandRegistrar:
|
|||||||
context_note=context_note,
|
context_note=context_note,
|
||||||
link_outputs=link_outputs,
|
link_outputs=link_outputs,
|
||||||
create_missing_active_skills_dir=create_missing_active_skills_dir,
|
create_missing_active_skills_dir=create_missing_active_skills_dir,
|
||||||
|
extension_id=manifest.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def unregister_commands(
|
def unregister_commands(
|
||||||
@@ -2688,7 +2692,12 @@ class ConfigManager:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return yaml.safe_load(file_path.read_text(encoding="utf-8")) or {}
|
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 {}
|
||||||
except (yaml.YAMLError, OSError, UnicodeError):
|
except (yaml.YAMLError, OSError, UnicodeError):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|||||||
@@ -1178,12 +1178,18 @@ class YamlIntegration(IntegrationBase):
|
|||||||
default_flow_style=False,
|
default_flow_style=False,
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
# Indent the body for YAML block scalar
|
# 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.
|
||||||
indented = "\n".join(f" {line}" for line in body.split("\n"))
|
indented = "\n".join(f" {line}" for line in body.split("\n"))
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
header_yaml,
|
header_yaml,
|
||||||
"prompt: |",
|
"prompt: |2",
|
||||||
indented,
|
indented,
|
||||||
"",
|
"",
|
||||||
f"# Source: {source_id}",
|
f"# Source: {source_id}",
|
||||||
|
|||||||
@@ -253,6 +253,11 @@ class HermesIntegration(SkillsIntegration):
|
|||||||
"""
|
"""
|
||||||
args = [self._resolve_executable(), "chat", "-Q"]
|
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:
|
if model:
|
||||||
args.extend(["-m", model])
|
args.extend(["-m", model])
|
||||||
if output_json:
|
if output_json:
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
"""Shared test helpers for integration tests."""
|
"""Shared test helpers for integration tests."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from specify_cli.integrations.base import MarkdownIntegration
|
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):
|
class StubIntegration(MarkdownIntegration):
|
||||||
"""Minimal concrete integration for testing."""
|
"""Minimal concrete integration for testing."""
|
||||||
|
|
||||||
|
|||||||
21
tests/integrations/test_home_isolation.py
Normal file
21
tests/integrations/test_home_isolation.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""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()
|
||||||
@@ -184,6 +184,23 @@ class YamlIntegrationTests:
|
|||||||
assert "scripts:" not in parsed["prompt"]
|
assert "scripts:" not in parsed["prompt"]
|
||||||
assert "---" 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):
|
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||||
"""The generated plan command must not carry a context-file placeholder.
|
"""The generated plan command must not carry a context-file placeholder.
|
||||||
|
|
||||||
|
|||||||
@@ -353,3 +353,38 @@ class TestHermesInitFlow:
|
|||||||
if "agent-context" not in d.name
|
if "agent-context" not in d.name
|
||||||
]
|
]
|
||||||
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
|
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"
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ 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:
|
def _posix_path(value: str | None) -> str | None:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -87,16 +100,6 @@ def _paths_overlap(first: str | None, second: str | None) -> bool:
|
|||||||
return False
|
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:
|
class TestRegistry:
|
||||||
def test_registry_is_dict(self):
|
def test_registry_is_dict(self):
|
||||||
assert isinstance(INTEGRATION_REGISTRY, dict)
|
assert isinstance(INTEGRATION_REGISTRY, dict)
|
||||||
@@ -162,6 +165,15 @@ class TestRegistrarKeyAlignment:
|
|||||||
class TestMultiInstallSafeContracts:
|
class TestMultiInstallSafeContracts:
|
||||||
"""Declared safe integrations must stay isolated from each other."""
|
"""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())
|
@pytest.mark.parametrize("key", _multi_install_safe_keys())
|
||||||
def test_safe_integrations_have_static_isolated_paths(self, key):
|
def test_safe_integrations_have_static_isolated_paths(self, key):
|
||||||
assert _integration_root_dir(key), (
|
assert _integration_root_dir(key), (
|
||||||
@@ -187,62 +199,77 @@ class TestMultiInstallSafeContracts:
|
|||||||
f"{_integration_commands_dir(second)!r}"
|
f"{_integration_commands_dir(second)!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
@pytest.mark.parametrize(
|
||||||
|
"ordered_keys",
|
||||||
|
_multi_install_safe_orders(),
|
||||||
|
ids=_multi_install_safe_order_id,
|
||||||
|
)
|
||||||
def test_safe_integrations_have_disjoint_manifests(
|
def test_safe_integrations_have_disjoint_manifests(
|
||||||
self,
|
self,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
first,
|
ordered_keys,
|
||||||
second,
|
|
||||||
):
|
):
|
||||||
for initial, additional in ((first, second), (second, first)):
|
# The pairwise disjointness contract is only meaningful with at least
|
||||||
project_root = tmp_path / f"project-{initial}-{additional}"
|
# two safe integrations. Guard so a shrunken registry fails loudly here
|
||||||
project_root.mkdir()
|
# rather than passing vacuously (or tripping over ordered_keys[0] below).
|
||||||
runner = CliRunner()
|
assert len(ordered_keys) >= 2, (
|
||||||
|
f"expected at least two multi-install-safe integrations, got {ordered_keys}"
|
||||||
|
)
|
||||||
|
|
||||||
original_cwd = os.getcwd()
|
project_root = tmp_path / "project"
|
||||||
try:
|
project_root.mkdir()
|
||||||
os.chdir(project_root)
|
runner = CliRunner()
|
||||||
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(
|
install_result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["integration", "install", additional, "--script", "sh"],
|
["integration", "install", key, "--script", "sh"],
|
||||||
catch_exceptions=False,
|
catch_exceptions=False,
|
||||||
)
|
)
|
||||||
assert install_result.exit_code == 0, install_result.output
|
assert install_result.exit_code == 0, install_result.output
|
||||||
finally:
|
finally:
|
||||||
os.chdir(original_cwd)
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
initial_manifest = json.loads(
|
integrations_dir = project_root / ".specify" / "integrations"
|
||||||
(
|
manifests = {}
|
||||||
project_root / ".specify" / "integrations" / f"{initial}.manifest.json"
|
for key in ordered_keys:
|
||||||
).read_text(encoding="utf-8")
|
manifest = json.loads(
|
||||||
)
|
(integrations_dir / f"{key}.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())
|
||||||
|
|
||||||
initial_files = set(initial_manifest.get("files", {}))
|
for first, second in _multi_install_safe_pairs():
|
||||||
additional_files = set(additional_manifest.get("files", {}))
|
overlap = manifests[first] & manifests[second]
|
||||||
|
assert not overlap, (
|
||||||
assert initial_files.isdisjoint(additional_files), (
|
f"{first} and {second} are declared multi-install safe but both manage "
|
||||||
f"{initial} and {additional} are declared multi-install safe but both manage "
|
f"these files: {sorted(overlap)}"
|
||||||
f"these files: {sorted(initial_files & additional_files)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
339
tests/test_check_prerequisites_python_parity.py
Normal file
339
tests/test_check_prerequisites_python_parity.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
"""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,6 +850,67 @@ class TestExtensionSkillRegistration:
|
|||||||
assert ".specify/templates/checklist.md" in content
|
assert ".specify/templates/checklist.md" in content
|
||||||
assert ".specify/memory/constitution.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):
|
def test_missing_command_file_skipped(self, skills_project, temp_dir):
|
||||||
"""Commands with missing source files should be skipped gracefully."""
|
"""Commands with missing source files should be skipped gracefully."""
|
||||||
project_dir, skills_dir = skills_project
|
project_dir, skills_dir = skills_project
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from specify_cli.extensions import (
|
|||||||
ExtensionRegistry,
|
ExtensionRegistry,
|
||||||
ExtensionManager,
|
ExtensionManager,
|
||||||
CommandRegistrar,
|
CommandRegistrar,
|
||||||
|
ConfigManager,
|
||||||
HookExecutor,
|
HookExecutor,
|
||||||
ExtensionCatalog,
|
ExtensionCatalog,
|
||||||
ExtensionError,
|
ExtensionError,
|
||||||
@@ -1146,10 +1147,12 @@ class TestExtensionManager:
|
|||||||
context_note=None,
|
context_note=None,
|
||||||
link_outputs=False,
|
link_outputs=False,
|
||||||
create_missing_active_skills_dir=False,
|
create_missing_active_skills_dir=False,
|
||||||
|
extension_id=None,
|
||||||
):
|
):
|
||||||
captured["create_missing_active_skills_dir"] = (
|
captured["create_missing_active_skills_dir"] = (
|
||||||
create_missing_active_skills_dir
|
create_missing_active_skills_dir
|
||||||
)
|
)
|
||||||
|
captured["extension_id"] = extension_id
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -1163,6 +1166,7 @@ class TestExtensionManager:
|
|||||||
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
|
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
|
||||||
|
|
||||||
assert captured["create_missing_active_skills_dir"] is False
|
assert captured["create_missing_active_skills_dir"] is False
|
||||||
|
assert captured["extension_id"] == manifest.id
|
||||||
|
|
||||||
def test_install_duplicate(self, extension_dir, project_dir):
|
def test_install_duplicate(self, extension_dir, project_dir):
|
||||||
"""Test installing already installed extension."""
|
"""Test installing already installed extension."""
|
||||||
@@ -1694,6 +1698,29 @@ $ARGUMENTS
|
|||||||
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
|
assert adjusted["scripts"]["sh"] == ".specify/extensions/test-ext/scripts/setup.sh {ARGS}"
|
||||||
assert adjusted["scripts"]["ps"] == ".specify/scripts/powershell/setup-plan.ps1 {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):
|
def test_rewrite_project_relative_paths_preserves_extension_local_body_paths(self):
|
||||||
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
|
"""Body rewrites should preserve extension-local assets while fixing top-level refs."""
|
||||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||||
@@ -1708,6 +1735,24 @@ $ARGUMENTS
|
|||||||
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
|
assert ".specify/extensions/test-ext/templates/spec.md" in rewritten
|
||||||
assert ".specify/scripts/bash/setup-plan.sh" 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):
|
def test_render_toml_command_handles_embedded_triple_double_quotes(self):
|
||||||
"""TOML renderer should stay valid when body includes triple double-quotes."""
|
"""TOML renderer should stay valid when body includes triple double-quotes."""
|
||||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||||
@@ -7492,3 +7537,52 @@ 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 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"]
|
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
|
||||||
|
|||||||
@@ -222,3 +222,39 @@ def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch):
|
|||||||
# A relative path containing ':' but no '://' is still a local path.
|
# A relative path containing ':' but no '://' is still a local path.
|
||||||
source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50)
|
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
|
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