Compare commits

...

11 Commits

Author SHA1 Message Date
github-actions[bot]
e08236f0b4 chore: bump version to 0.12.6 2026-07-07 16:03:01 +00:00
Marsel Safin
2b5e175440 fix(bundler): validate catalog URLs in catalog add (HTTPS-only, require host) (#3367)
* fix(bundler): validate catalog URLs in `catalog add` (HTTPS-only, require host)

add_source persisted remote catalog URLs without the HTTPS/host checks
that specify_cli.catalogs (#3210) and the bundler adapters (#3333)
enforce, and an unclosed IPv6 bracket escaped as a raw ValueError.
Mirror the catalogs.py validation for http(s) schemes and wrap urlparse
so malformed input raises BundlerError.

Fixes #3366

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: correct config filename and validation reference in comment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-07 11:01:46 -05:00
github-actions[bot]
0e40438903 Update Ralph Loop extension to v1.2.1 (#3365)
Update ralph extension submitted by @Rubiss:
- extensions/catalog.community.json (version, download_url, speckit_version, tools, tags, updated_at)

Closes #3337

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-07 10:55:18 -05:00
Zhiyao Wen
1930f89d17 fix extension-local script path rewriting (#3364)
Co-authored-by: Zhiyao <zhiyao@ZhiyaodeMacBook-Air.local>
2026-07-07 10:51:50 -05:00
github-actions[bot]
4bb5166445 Add Charter extension to community catalog (#3363)
Add charter extension submitted by @Huljo to:
- extensions/catalog.community.json (alphabetical order)
- docs/community/extensions.md community extensions table

Closes #3322

Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-07 09:53:05 -05:00
WOLIKIMCHENG
73f77c200f feat(scripts): add Python check-prerequisites PoC (#3302)
* feat(scripts): add Python check-prerequisites PoC

* fix(scripts): address check-prerequisites parity feedback

* test(scripts): label PowerShell prerequisite parity cases

---------

Co-authored-by: root <kinsonnee@gmail.com>
2026-07-06 17:54:31 -05:00
Pascal THUET
b8d27e472f test: reduce registry manifest test repetition (#3146)
* test: isolate integration test home

Assisted-by: Codex (model: GPT-5, autonomous)

* test: reduce registry manifest test repetition

Assisted-by: Codex (model: GPT-5, autonomous)

* test: clarify disjoint-manifest order rationale and guard safe set

Add a >=2 precondition, explain why two install orders are tested
(manifests are order-independent; the orders only vary the init path),
and build the manifest map with a comprehension.

* test: rotate init coverage for manifest isolation

Assisted-by: Codex (model: GPT-5, autonomous)

* test: assert integration home isolation

Assisted-by: Codex (model: GPT-5, autonomous)

* test: guard multi-install manifest rotations

Assisted-by: Codex (model: GPT-5, autonomous)
2026-07-06 17:50:33 -05:00
Ali jawwad
587b1859fa fix(integrations): hermes honors SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS (#3346)
HermesIntegration.build_exec_args routed argv[0] through
_resolve_executable() but never called _apply_extra_args_env_var(), so
the documented per-integration extra-args env hook was silently dropped
for hermes — the same class of bug fixed for cursor-agent in #3265.
Insert the hook after the base 'chat -Q' command and before Spec Kit's
canonical -m/--json/-s/-q flags (mirrors opencode), so operator args
can't displace or clobber the canonical flags.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:38:39 -05:00
Ali jawwad
52480ee50f fix(extensions): coerce non-mapping YAML config roots to {} in ConfigManager (#3345)
ConfigManager._load_yaml_config returned yaml.safe_load(...) or {}, which
only guards falsy roots — a truthy non-mapping root (a YAML list or
scalar) flows straight into _merge_configs, whose .items() raises
AttributeError. get_config()/has_value()/get_value() then crash, and via
should_execute_hook's blanket 'except Exception: return False' every
config-based hook condition for that extension is silently disabled.
Coerce a non-dict root to {}, mirroring the existing non-dict-root guard
in get_project_config(). Hardens all three call sites in one place.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:36:35 -05:00
Quratulain-bilal
d3e7b06fa7 fix(yaml): pin goose recipe prompt block-scalar indentation (#3343)
the goose recipe renderer emitted the prompt body under a bare '|' block
scalar. yaml infers a plain block scalar's indentation from its first
non-empty line, so a command body whose first line is itself indented (a
markdown code block, a nested list item) made the parser expect that deeper
indent for the whole block and reject the later, shallower lines - the
generated .goose recipe then failed to parse.

use an explicit '|2' indentation indicator so the block is always read at
2 spaces regardless of the body. added a regression test that round-trips
an indented-first-line body through the yaml parser.
2026-07-06 17:34:16 -05:00
Manfred Riem
de6a04eaad chore: release 0.12.5, begin 0.12.6.dev0 development (#3381)
* chore: bump version to 0.12.5

* chore: begin 0.12.6.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-06 16:51:18 -05:00
20 changed files with 1287 additions and 78 deletions

View File

@@ -2,6 +2,36 @@
<!-- 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
### Changed

View File

@@ -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) |
| 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) |

View File

@@ -1,6 +1,6 @@
{
"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",
"extensions": {
"aide": {
@@ -670,6 +670,40 @@
"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",
@@ -2679,8 +2713,8 @@
"id": "ralph",
"description": "Autonomous implementation loop using AI agent CLI",
"author": "Rubiss",
"version": "1.1.1",
"download_url": "https://github.com/Rubiss-Projects/spec-kit-ralph/archive/refs/tags/v1.1.1.zip",
"version": "1.2.1",
"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",
"homepage": "https://github.com/Rubiss-Projects/spec-kit-ralph",
"documentation": "https://github.com/Rubiss-Projects/spec-kit-ralph/blob/main/README.md",
@@ -2689,7 +2723,7 @@
"category": "code",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0",
"speckit_version": ">=0.8.5",
"tools": [
{
"name": "copilot",
@@ -2699,6 +2733,10 @@
"name": "codex",
"required": false
},
{
"name": "claude",
"required": false
},
{
"name": "git",
"required": true
@@ -2714,13 +2752,14 @@
"automation",
"loop",
"copilot",
"codex"
"codex",
"claude"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-03-09T00:00:00Z",
"updated_at": "2026-06-05T03:11:06Z"
"updated_at": "2026-07-06T00:00:00Z"
},
"reconcile": {
"name": "Reconcile Extension",

View File

@@ -1,6 +1,6 @@
[project]
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)."
readme = "README.md"
requires-python = ">=3.11"

View 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
View 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}"

View File

@@ -148,7 +148,9 @@ class CommandRegistrar:
)
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.
Rewrites known repo-relative and top-level script paths under the
@@ -158,6 +160,7 @@ class CommandRegistrar:
Args:
frontmatter: Frontmatter dictionary
extension_id: Extension id when rendering extension-owned commands.
Returns:
Modified frontmatter with normalized project paths
@@ -168,11 +171,15 @@ 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)
scripts[key] = self.rewrite_project_relative_paths(
script_path, extension_id=extension_id
)
return frontmatter
@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."""
if not isinstance(text, str) or not text:
return text
@@ -184,10 +191,18 @@ class CommandRegistrar:
):
text = text.replace(old, new)
# Only rewrite top-level style references so extension-local paths like
# ".specify/extensions/<ext>/scripts/..." remain intact.
# 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/"
)
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(
r'(^|[\s`"\'(])(?:\.?/)?templates/', r"\1.specify/templates/", text
)
@@ -312,6 +327,7 @@ 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.
@@ -331,7 +347,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
agent_name, frontmatter, body, project_root, extension_id=extension_id
)
description = frontmatter.get(
@@ -393,7 +409,11 @@ class CommandRegistrar:
@staticmethod
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:
"""Resolve script placeholders for skills-backed agents."""
if not isinstance(frontmatter, dict):
@@ -433,7 +453,9 @@ class CommandRegistrar:
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(
self, content: str, from_placeholder: str, to_placeholder: str
@@ -528,6 +550,7 @@ 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.
@@ -545,6 +568,7 @@ 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
@@ -614,7 +638,9 @@ class CommandRegistrar:
frontmatter[key] = core_frontmatter[key]
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", []):
frontmatter.pop(key, None)
@@ -653,10 +679,11 @@ 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
agent_name, frontmatter, body, project_root, extension_id=extension_id
)
body = self._convert_argument_placeholder(
body, "$ARGUMENTS", agent_config["args"]
@@ -666,7 +693,7 @@ class CommandRegistrar:
)
elif agent_config["format"] == "toml":
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, "$ARGUMENTS", agent_config["args"]
@@ -721,6 +748,7 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
elif agent_config["format"] == "markdown":
alias_output = self.render_markdown_command(
@@ -750,6 +778,7 @@ class CommandRegistrar:
source_id,
cmd_file,
project_root,
extension_id=extension_id,
)
alias_file = (
@@ -881,6 +910,7 @@ 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.
@@ -897,6 +927,7 @@ 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
@@ -999,6 +1030,7 @@ class CommandRegistrar:
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
extension_id=extension_id,
)
if registered:
results[agent_name] = registered
@@ -1023,6 +1055,7 @@ 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.
@@ -1038,6 +1071,7 @@ 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
@@ -1066,6 +1100,7 @@ class CommandRegistrar:
context_note=context_note,
_resolved_dir=agent_dir,
link_outputs=link_outputs,
extension_id=extension_id,
)
if registered:
results[agent_name] = registered

View File

@@ -95,7 +95,11 @@ 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
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
@@ -137,7 +141,10 @@ def add_source(
url = url.strip()
if not url:
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):
raise BundlerError(f"Invalid catalog url: '{url}'.")
# 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}'. "
"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)

View File

@@ -1075,9 +1075,11 @@ class ExtensionManager:
pass # best-effort cleanup
continue
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(
selected_ai, frontmatter, body, self.project_root
selected_ai, frontmatter, body, self.project_root, extension_id=manifest.id
)
original_desc = frontmatter.get("description", "")
@@ -1958,6 +1960,7 @@ class CommandRegistrar:
project_root,
context_note=context_note,
link_outputs=link_outputs,
extension_id=manifest.id,
)
def register_commands_for_all_agents(
@@ -1978,6 +1981,7 @@ 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(
@@ -2688,7 +2692,12 @@ class ConfigManager:
return {}
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):
return {}

View File

@@ -1178,12 +1178,18 @@ class YamlIntegration(IntegrationBase):
default_flow_style=False,
).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"))
lines = [
header_yaml,
"prompt: |",
"prompt: |2",
indented,
"",
f"# Source: {source_id}",

View File

@@ -253,6 +253,11 @@ 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:

View File

@@ -1,8 +1,24 @@
"""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."""

View 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()

View File

@@ -184,6 +184,23 @@ 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.

View File

@@ -353,3 +353,38 @@ 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"

View File

@@ -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:
if not value:
return None
@@ -87,16 +100,6 @@ 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)
@@ -162,6 +165,15 @@ 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), (
@@ -187,62 +199,77 @@ class TestMultiInstallSafeContracts:
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(
self,
tmp_path,
first,
second,
ordered_keys,
):
for initial, additional in ((first, second), (second, first)):
project_root = tmp_path / f"project-{initial}-{additional}"
project_root.mkdir()
runner = CliRunner()
# 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}"
)
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
project_root = tmp_path / "project"
project_root.mkdir()
runner = CliRunner()
# 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", additional, "--script", "sh"],
["integration", "install", key, "--script", "sh"],
catch_exceptions=False,
)
assert install_result.exit_code == 0, install_result.output
finally:
os.chdir(original_cwd)
finally:
os.chdir(original_cwd)
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")
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")
)
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", {}))
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)}"
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)}"
)

View 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"

View File

@@ -850,6 +850,67 @@ 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

View File

@@ -31,6 +31,7 @@ from specify_cli.extensions import (
ExtensionRegistry,
ExtensionManager,
CommandRegistrar,
ConfigManager,
HookExecutor,
ExtensionCatalog,
ExtensionError,
@@ -1146,10 +1147,12 @@ 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(
@@ -1163,6 +1166,7 @@ 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."""
@@ -1694,6 +1698,29 @@ $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
@@ -1708,6 +1735,24 @@ $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
@@ -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 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

View File

@@ -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.
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")