Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
800fe33f59 chore: bump version to 0.9.3 2026-06-03 21:25:59 +00:00
27 changed files with 138 additions and 1870 deletions

View File

@@ -423,17 +423,6 @@ When an issue exists, include its number immediately after the prefix — this i
---
## Responding to PR Review Comments
- If you are an agent working on behalf of a human, **disclose your identity in your PR comment** — name the agent (and model, if applicable) and the human you are acting for (e.g., "Posted on behalf of @user by GitHub Copilot (model: <name-if-known>)").
- Post **one** top-level summary comment per review round listing what changed and the commit SHA. Do not reply on every individual comment.
- Reply inline only when context is needed (disagreement, deferral, non-obvious fix). Keep it to a sentence or two.
- **Never click "Resolve conversation"** — that belongs to the reviewer or PR author.
- No emoji, no celebratory framing, no checklist mirroring the reviewer's items, no restating what the reviewer wrote.
- Re-request review once per round (when all feedback is addressed), not after every intermediate push.
---
## Common Pitfalls
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.

View File

@@ -2,20 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.9.4] - 2026-06-04
### Changed
- feat(workflows): add JSON output for workflow run resume and status (#2814)
- Update workflow-preset community catalog to v1.3.2 (#2841)
- fix: recover active skills registration for extensions (#2803)
- fix(cursor-agent): enable headless CLI dispatch end-to-end (-p --trust --approve-mcps --force + Windows .cmd shim resolution) (#2631)
- Update Superpowers Implementation Bridge extension to v1.0.2 (#2852)
- docs(agents): add PR review response guidance to AGENTS.md (#2850)
- Allow `specify workflow run` to execute YAML files without a project (#2825)
- feat(extensions): add --force flag to extension add for overwrite reinstall (#2530)
- chore: release 0.9.3, begin 0.9.4.dev0 development (#2836)
## [0.9.3] - 2026-06-03
### Changed

View File

@@ -11,7 +11,6 @@ specify workflow run <source>
| Option | Description |
| ------------------- | -------------------------------------------------------- |
| `-i` / `--input` | Pass input values as `key=value` (repeatable) |
| `--json` | Emit the run outcome as a single JSON object |
Runs a workflow from a catalog ID, URL, or local file path. Inputs declared by the workflow can be provided via `--input` or will be prompted interactively.
@@ -21,25 +20,7 @@ Example:
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full
```
With `--json`, a single machine-readable object is printed instead of formatted text (the default output is unchanged when the flag is omitted):
```bash
specify workflow run my-pipeline.yml --json
```
```json
{
"run_id": "662bf791",
"workflow_id": "build-and-review",
"status": "paused",
"current_step_id": "review",
"current_step_index": 0
}
```
`workflow_id` is the `workflow.id` declared inside the YAML, not the file name. The object is printed exactly as shown — pretty-printed with two-space indentation, on plain stdout with no Rich markup — so it always parses. While the workflow runs under `--json`, any progress a step would print (for example a gate prompt, or output from a prompt step's CLI subprocess) is redirected to stderr, so stdout carries only the JSON object. Read the object from stdout; leave stderr attached to the terminal or capture it separately.
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.{yml,yaml}>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
> **Note:** All workflow commands require a project already initialized with `specify init`.
## Resume a Workflow
@@ -50,7 +31,6 @@ specify workflow resume <run_id>
| Option | Description |
| ------------------- | -------------------------------------------------------- |
| `-i` / `--input` | Updated input values as `key=value` (repeatable) |
| `--json` | Emit the resume outcome as a single JSON object |
Resumes a paused or failed workflow run from the exact step where it stopped. Useful after responding to a gate step or fixing an issue that caused a failure.
@@ -66,10 +46,6 @@ specify workflow resume <run_id> --input cmd="exit 0"
specify workflow status [<run_id>]
```
| Option | Description |
| ------------------- | -------------------------------------------------------- |
| `--json` | Emit run status (or the runs list) as a JSON object |
Shows the status of a specific run, or lists all runs if no ID is given. Run states: `created`, `running`, `completed`, `paused`, `failed`, `aborted`.
## List Installed Workflows

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-04T00:00:00Z",
"updated_at": "2026-06-02T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -2756,8 +2756,8 @@
"id": "speckit-superpowers-bridge",
"description": "Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent.",
"author": "lihan3238",
"version": "1.0.2",
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v1.0.2/speckit-superpowers-bridge-v1.0.2.zip",
"version": "0.7.0",
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.7.0/speckit-superpowers-bridge-v0.7.0.zip",
"repository": "https://github.com/lihan3238/speckit-superpowers-bridge",
"homepage": "https://github.com/lihan3238/speckit-superpowers-bridge",
"documentation": "https://github.com/lihan3238/speckit-superpowers-bridge#readme",
@@ -2798,7 +2798,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-15T00:00:00Z",
"updated_at": "2026-06-04T00:00:00Z"
"updated_at": "2026-05-28T00:00:00Z"
},
"speckit-utils": {
"name": "SDD Utilities",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-03T00:00:00Z",
"updated_at": "2026-05-31T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
@@ -542,7 +542,7 @@
],
"created_at": "2026-04-30T00:00:00Z",
"updated_at": "2026-04-30T00:00:00Z"
},
},
"toc-navigation": {
"name": "Table of Contents Navigation",
"id": "toc-navigation",
@@ -595,11 +595,11 @@
"workflow-preset": {
"name": "Workflow Preset",
"id": "workflow-preset",
"version": "1.3.2",
"version": "1.3.1",
"description": "Behavior-first specification, design artifacts, and agent-native handoff orchestration.",
"author": "bigsmartben",
"repository": "https://github.com/bigsmartben/spec-kit-workflow-preset",
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.2/spec-kit-workflow-preset-v1.3.2.zip",
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.1/spec-kit-workflow-preset-v1.3.1.zip",
"homepage": "https://github.com/bigsmartben/spec-kit-workflow-preset",
"documentation": "https://github.com/bigsmartben/spec-kit-workflow-preset/blob/main/README.md",
"license": "MIT",
@@ -618,7 +618,7 @@
"handoff"
],
"created_at": "2026-05-27T00:00:00Z",
"updated_at": "2026-06-03T00:00:00Z"
"updated_at": "2026-05-28T00:00:00Z"
}
}
}

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.9.4"
version = "0.9.3"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [

View File

@@ -26,7 +26,6 @@ Or install globally:
specify init --here
"""
import contextlib
import os
import sys
import zipfile
@@ -87,12 +86,6 @@ from ._agent_config import (
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
)
from ._init_options import (
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
is_ai_skills_enabled as _is_ai_skills_enabled,
load_init_options as load_init_options,
save_init_options as save_init_options,
)
app = typer.Typer(
name="specify",
@@ -266,6 +259,65 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
for f in failures:
console.print(f" - {f}")
INIT_OPTIONS_FILE = ".specify/init-options.json"
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
"""Persist the CLI options used during ``specify init``.
Writes a small JSON file to ``.specify/init-options.json`` so that
later operations (e.g. preset install) can adapt their behaviour
without scanning the filesystem.
"""
dest = project_path / INIT_OPTIONS_FILE
dest.parent.mkdir(parents=True, exist_ok=True)
# Write JSON as real UTF-8 instead of ``\uXXXX`` escape sequences
# (``ensure_ascii=False``) and pin the file encoding to match.
#
# The default ``json.dumps`` output is ASCII-only — any non-ASCII
# character is encoded as a ``\uXXXX`` escape — so without the
# ``ensure_ascii=False`` flip below the encoding pin alone would be
# a no-op for any payload we plausibly write today. We pair the two
# so the on-disk bytes match a human's expectation of "this file is
# UTF-8" (greppable, readable in editors that don't decode JSON
# escapes, friendly to peers running ``cat`` or ``Get-Content``) and
# so the encoding pin is a real contract instead of a future hedge.
#
# ``Path.write_text`` without ``encoding=`` falls back to the system
# locale codec (cp1252 / gb2312 / cp932 on Windows), which would
# mis-encode non-ASCII bytes locally and produce a file a peer with
# a different locale couldn't decode. The sibling integration-
# catalog writer in ``integrations/catalog.py`` pins
# ``encoding="utf-8"`` for the same reason.
dest.write_text(
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
encoding="utf-8",
)
def load_init_options(project_path: Path) -> dict[str, Any]:
"""Load the init options previously saved by ``specify init``.
Returns an empty dict if the file does not exist or cannot be parsed.
"""
path = project_path / INIT_OPTIONS_FILE
if not path.exists():
return {}
try:
# Match the explicit UTF-8 used by ``save_init_options``; without
# it ``read_text`` falls back to the system codec on Windows and
# raises ``UnicodeDecodeError`` on any file containing the
# multi-byte UTF-8 sequences ``save_init_options`` now writes
# directly. ``UnicodeDecodeError`` is a subclass of
# ``ValueError``, not ``OSError`` / ``json.JSONDecodeError``, so
# it must be listed explicitly here to preserve the existing
# "fall back to empty dict" contract for corrupted / foreign-
# codec files.
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return {}
# ---------------------------------------------------------------------------
# Agent-context extension config helpers
# ---------------------------------------------------------------------------
@@ -349,10 +401,10 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
"""Return the active skills directory, creating it on demand when enabled.
Reads ``.specify/init-options.json`` to determine whether skills are
enabled and which agent was selected. Only ``ai_skills`` set to boolean
``True`` creates the directory safely (symlink/containment checks); when
``ai_skills`` is not boolean ``True``, only Kimi's native-skills fallback
is honoured, and the native skills directory must already exist.
enabled and which agent was selected. When ``ai_skills`` is true the
directory is created safely (symlink/containment checks); when false
only Kimi's native-skills fallback is honoured (directory must already
exist).
Returns:
The skills directory ``Path``, or ``None`` if skills are not active.
@@ -373,15 +425,14 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
if not isinstance(agent, str) or not agent:
return None
ai_skills_enabled = _is_ai_skills_enabled(opts)
ai_skills_enabled = bool(opts.get("ai_skills"))
if not ai_skills_enabled and agent != "kimi":
return None
skills_dir = _get_skills_dir(project_root, agent)
if not ai_skills_enabled:
# Kimi native-skills fallback when ai_skills is not boolean True:
# use the native skills directory only if it already exists.
# Kimi native-skills fallback: use the directory only if it exists.
if not skills_dir.is_dir():
return None
_ensure_safe_shared_directory(
@@ -390,7 +441,7 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
)
return skills_dir
# ai_skills is boolean True: create the directory safely.
# ai_skills is explicitly enabled — create the directory safely.
_ensure_safe_shared_directory(
project_root, skills_dir, context="agent skills directory",
)
@@ -1560,7 +1611,6 @@ def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
force: bool = typer.Option(False, "--force", help="Overwrite if already installed"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
@@ -1575,9 +1625,6 @@ def extension_add(
manager = ExtensionManager(project_root)
speckit_version = get_speckit_version()
if force:
console.print("[yellow]--force:[/yellow] Will overwrite if already installed")
# Prompt for URL-based installs BEFORE the spinner so the user can
# actually see and respond to the confirmation (the Rich status
# spinner overwrites the typer.confirm prompt line, making it appear
@@ -1628,15 +1675,11 @@ def extension_add(
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
raise typer.Exit(1)
if force:
console.print(f"[yellow]--force:[/yellow] Installing from [cyan]{source_path}[/cyan] (will overwrite if already installed)...")
manifest = manager.install_from_directory(
source_path,
speckit_version,
priority=priority,
link_commands=True,
force=force
)
elif from_url:
@@ -1658,7 +1701,7 @@ def extension_add(
zip_path.write_bytes(zip_data)
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}")
raise typer.Exit(1)
@@ -1671,9 +1714,7 @@ def extension_add(
# Try bundled extensions first (shipped with spec-kit)
bundled_path = _locate_bundled_extension(extension)
if bundled_path is not None:
manifest = manager.install_from_directory(
bundled_path, speckit_version, priority=priority, force=force
)
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
else:
# Install from catalog (also resolves display names to IDs)
catalog = ExtensionCatalog(project_root)
@@ -1694,9 +1735,7 @@ def extension_add(
if resolved_id != extension:
bundled_path = _locate_bundled_extension(resolved_id)
if bundled_path is not None:
manifest = manager.install_from_directory(
bundled_path, speckit_version, priority=priority, force=force
)
manifest = manager.install_from_directory(bundled_path, speckit_version, priority=priority)
if bundled_path is None:
# Bundled extensions without a download URL must come from the local package
@@ -1732,7 +1771,7 @@ def extension_add(
try:
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
finally:
# Clean up downloaded ZIP
if zip_path.exists():
@@ -2694,95 +2733,22 @@ def _parse_input_values(input_values: list[str] | None) -> dict[str, Any]:
return inputs
def _workflow_run_payload(state: Any) -> dict[str, Any]:
"""Machine-readable summary of a run/resume outcome."""
return {
"run_id": state.run_id,
"workflow_id": state.workflow_id,
"status": state.status.value,
"current_step_id": state.current_step_id,
"current_step_index": state.current_step_index,
}
def _emit_workflow_json(payload: dict[str, Any]) -> None:
"""Write a workflow payload as machine-readable JSON to stdout.
Uses the builtin ``print`` rather than ``console.print`` so Rich
markup interpretation, syntax highlighting, and line-wrapping can
never alter the emitted JSON.
"""
print(json.dumps(payload, indent=2))
@contextlib.contextmanager
def _stdout_to_stderr_when(active: bool):
"""Redirect everything written to stdout onto stderr while *active*.
Suppressing the banner and the step-start callback is not enough to
keep a ``--json`` stream clean: individual steps may still write to
stdout while the engine runs — the gate step prints its prompt,
and the prompt step runs a subprocess that inherits the process's
stdout file descriptor. Either would corrupt the single JSON object.
Redirecting at the file-descriptor level (``dup2``) captures both
Python-level writes and inherited-fd subprocess output, so step
progress lands on stderr (still visible to a human) while stdout
carries only the emitted JSON. A no-op when *active* is false.
"""
if not active:
yield
return
sys.stdout.flush()
saved_stdout_fd = os.dup(1)
try:
os.dup2(2, 1) # fd 1 (stdout) now points at fd 2 (stderr)
with contextlib.redirect_stdout(sys.stderr):
yield
finally:
sys.stdout.flush()
os.dup2(saved_stdout_fd, 1) # restore the real stdout
os.close(saved_stdout_fd)
@workflow_app.command("run")
def workflow_run(
source: str = typer.Argument(..., help="Workflow ID or YAML file path"),
input_values: list[str] | None = typer.Option(
None, "--input", "-i", help="Input values as key=value pairs"
),
json_output: bool = typer.Option(
False,
"--json",
help="Emit the run outcome as a single JSON object instead of formatted text.",
),
):
"""Run a workflow from an installed ID or local YAML path."""
from .workflows.engine import WorkflowEngine
source_path = Path(source).expanduser()
is_file_source = source_path.suffix.lower() in (".yml", ".yaml") and source_path.is_file()
if is_file_source:
# When running a YAML file directly, use cwd as project root
# without requiring a .specify/ project directory.
project_root = Path.cwd()
specify_dir = project_root / ".specify"
if specify_dir.is_symlink():
console.print("[red]Error:[/red] Refusing to use symlinked .specify path in current directory")
raise typer.Exit(1)
if specify_dir.exists() and not specify_dir.is_dir():
console.print("[red]Error:[/red] .specify path exists but is not a directory")
raise typer.Exit(1)
else:
project_root = _require_specify_project()
project_root = _require_specify_project()
engine = WorkflowEngine(project_root)
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
try:
definition = engine.load_workflow(source_path if is_file_source else source)
definition = engine.load_workflow(source)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Workflow not found: {source}")
raise typer.Exit(1)
@@ -2801,13 +2767,11 @@ def workflow_run(
# Parse inputs
inputs = _parse_input_values(input_values)
if not json_output:
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
console.print(f"[dim]Version: {definition.version}[/dim]\n")
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
console.print(f"[dim]Version: {definition.version}[/dim]\n")
try:
with _stdout_to_stderr_when(json_output):
state = engine.execute(definition, inputs)
state = engine.execute(definition, inputs)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
@@ -2815,10 +2779,6 @@ def workflow_run(
console.print(f"[red]Workflow failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
_emit_workflow_json(_workflow_run_payload(state))
return
status_colors = {
"completed": "green",
"paused": "yellow",
@@ -2839,25 +2799,18 @@ def workflow_resume(
input_values: list[str] | None = typer.Option(
None, "--input", "-i", help="Updated input values as key=value pairs"
),
json_output: bool = typer.Option(
False,
"--json",
help="Emit the resume outcome as a single JSON object instead of formatted text.",
),
):
"""Resume a paused or failed workflow run."""
from .workflows.engine import WorkflowEngine
project_root = _require_specify_project()
engine = WorkflowEngine(project_root)
if not json_output:
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
inputs = _parse_input_values(input_values)
try:
with _stdout_to_stderr_when(json_output):
state = engine.resume(run_id, inputs or None)
state = engine.resume(run_id, inputs or None)
except FileNotFoundError:
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
@@ -2868,10 +2821,6 @@ def workflow_resume(
console.print(f"[red]Resume failed:[/red] {exc}")
raise typer.Exit(1)
if json_output:
_emit_workflow_json(_workflow_run_payload(state))
return
status_colors = {
"completed": "green",
"paused": "yellow",
@@ -2885,11 +2834,6 @@ def workflow_resume(
@workflow_app.command("status")
def workflow_status(
run_id: str | None = typer.Argument(None, help="Run ID to inspect (shows all if omitted)"),
json_output: bool = typer.Option(
False,
"--json",
help="Emit run status as a single JSON object instead of formatted text.",
),
):
"""Show workflow run status."""
from .workflows.engine import WorkflowEngine
@@ -2905,21 +2849,6 @@ def workflow_status(
console.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
if json_output:
# Build on the shared run/resume payload so the common fields
# (including current_step_index) stay identical across commands.
payload = {
**_workflow_run_payload(state),
"created_at": state.created_at,
"updated_at": state.updated_at,
"steps": {
sid: sd.get("status", "unknown")
for sid, sd in state.step_results.items()
},
}
_emit_workflow_json(payload)
return
status_colors = {
"completed": "green",
"paused": "yellow",
@@ -2947,22 +2876,6 @@ def workflow_status(
console.print(f" [{sc}]●[/{sc}] {step_id}: {s}")
else:
runs = engine.list_runs()
if json_output:
payload = {
"runs": [
{
"run_id": r["run_id"],
"workflow_id": r.get("workflow_id"),
"status": r.get("status", "unknown"),
"updated_at": r.get("updated_at"),
}
for r in runs
]
}
_emit_workflow_json(payload)
return
if not runs:
console.print("[yellow]No workflow runs found.[/yellow]")
return

View File

@@ -1,36 +0,0 @@
"""Helpers for interpreting persisted init options."""
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
INIT_OPTIONS_FILE = ".specify/init-options.json"
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
"""Persist the CLI options used during ``specify init``."""
dest = project_path / INIT_OPTIONS_FILE
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
json.dumps(options, indent=2, sort_keys=True, ensure_ascii=False),
encoding="utf-8",
)
def load_init_options(project_path: Path) -> dict[str, Any]:
"""Load persisted init options, returning an empty dict when unavailable."""
path = project_path / INIT_OPTIONS_FILE
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeError):
return {}
return payload if isinstance(payload, dict) else {}
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
"""Return True only when init options explicitly enable AI skills."""
return isinstance(opts, Mapping) and opts.get("ai_skills") is True

View File

@@ -15,8 +15,6 @@ from typing import Any, Dict, List, Optional
import yaml
from ._init_options import is_ai_skills_enabled, load_init_options
def _build_agent_configs() -> dict[str, Any]:
"""Derive CommandRegistrar.AGENT_CONFIGS from INTEGRATION_REGISTRY."""
@@ -361,6 +359,11 @@ class CommandRegistrar:
agent_name: str, frontmatter: dict, body: str, project_root: Path
) -> str:
"""Resolve script placeholders for skills-backed agents."""
try:
from . import load_init_options
except ImportError:
return body
if not isinstance(frontmatter, dict):
frontmatter = {}
@@ -471,29 +474,6 @@ class CommandRegistrar:
return False
return os.path.normpath(name) == name
@staticmethod
def _same_lexical_path(left: Path, right: Path) -> bool:
"""Compare paths after lexical normalization without resolving symlinks."""
return os.path.normcase(os.path.normpath(os.fspath(left))) == os.path.normcase(
os.path.normpath(os.fspath(right))
)
@staticmethod
def _active_skills_agent(project_root: Path) -> Optional[str]:
"""Return the initialized skills-backed agent, if skills mode is active."""
opts = load_init_options(project_root)
if not isinstance(opts, dict):
return None
agent = opts.get("ai")
if not isinstance(agent, str) or not agent:
return None
# Kimi is a native skills integration; when ai_skills is not boolean
# True, Kimi still uses its existing SKILL.md layout.
if not is_ai_skills_enabled(opts) and agent != "kimi":
return None
return agent
def register_commands(
self,
agent_name: str,
@@ -826,7 +806,6 @@ class CommandRegistrar:
project_root: Path,
context_note: str = None,
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
@@ -838,11 +817,6 @@ class CommandRegistrar:
context_note: Custom context comment for markdown output
link_outputs: If True, create dev-mode symlinks for rendered
command files when supported by the OS.
create_missing_active_skills_dir: If True, attempt missing-dir
recovery only for the active initialized skills-backed agent.
Recovery requires active skills mode (or Kimi's existing native
skills directory) and is skipped when safe resolution or
creation fails.
Returns:
Dictionary mapping agent names to list of registered commands
@@ -850,17 +824,7 @@ class CommandRegistrar:
results = {}
self._ensure_configs()
active_skills_agent = (
self._active_skills_agent(project_root)
if create_missing_active_skills_dir else None
)
active_created_skills_dir: Optional[Path] = None
for agent_name, agent_config in self.AGENT_CONFIGS.items():
active_skills_output = (
agent_name == active_skills_agent
and agent_config.get("extension") == "/SKILL.md"
)
recovered_active_skills_dir: Optional[Path] = None
# Check detect_dir first (project-local marker) if configured,
# falling back to the resolved dir for output. This prevents
# global dirs (e.g. ~/.hermes/skills) from causing false
@@ -868,55 +832,13 @@ class CommandRegistrar:
detect_dir_str = agent_config.get("detect_dir")
if detect_dir_str:
detect_path = project_root / detect_dir_str
if not detect_path.is_dir():
if not active_skills_output:
continue
try:
from . import resolve_active_skills_dir
recovered_active_skills_dir = (
resolve_active_skills_dir(project_root)
)
except (ValueError, OSError):
continue
if recovered_active_skills_dir is None or not detect_path.is_dir():
continue
active_created_skills_dir = recovered_active_skills_dir
if not detect_path.exists():
continue
agent_dir = self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
agent_dir_existed = agent_dir.is_dir()
register_missing_active_skills_agent = (
not agent_dir_existed
and active_skills_output
)
if register_missing_active_skills_agent:
if recovered_active_skills_dir is None:
try:
from . import resolve_active_skills_dir
recovered_active_skills_dir = (
resolve_active_skills_dir(project_root)
)
except (ValueError, OSError):
continue
if recovered_active_skills_dir is None:
continue
active_created_skills_dir = recovered_active_skills_dir
# Shared skill dirs such as .agents/skills should not make
# later integrations look detected when the active agent just
# recreated the directory during this registration pass.
created_by_active_agent = (
active_created_skills_dir is not None
and self._same_lexical_path(agent_dir, active_created_skills_dir)
and agent_name != active_skills_agent
)
should_register = (
agent_dir_existed and not created_by_active_agent
) or register_missing_active_skills_agent
if should_register:
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name,
@@ -930,16 +852,8 @@ class CommandRegistrar:
)
if registered:
results[agent_name] = registered
if register_missing_active_skills_agent:
active_created_skills_dir = (
recovered_active_skills_dir or agent_dir
)
except ValueError:
continue
except OSError:
if register_missing_active_skills_agent:
continue
raise
return results
@@ -978,12 +892,12 @@ class CommandRegistrar:
detect_dir_str = agent_config.get("detect_dir")
if detect_dir_str:
detect_path = project_root / detect_dir_str
if not detect_path.is_dir():
if not detect_path.exists():
continue
agent_dir = self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
if agent_dir.is_dir():
if agent_dir.exists():
try:
registered = self.register_commands(
agent_name,

View File

@@ -26,15 +26,14 @@ from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .catalogs import CatalogEntry as BaseCatalogEntry, CatalogStackBase
from ._init_options import is_ai_skills_enabled
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",
@@ -831,53 +830,15 @@ class ExtensionManager:
be created due to symlink, containment, or permission issues so
that callers can fall back gracefully.
"""
from . import (
_print_cli_warning,
load_init_options,
resolve_active_skills_dir,
)
def _ensure_usable(skills_dir: Path) -> Optional[Path]:
try:
skills_dir.mkdir(parents=True, exist_ok=True)
if not skills_dir.is_dir():
raise NotADirectoryError(f"{skills_dir} is not a directory")
except (OSError, ValueError) as exc:
_print_cli_warning(
"resolve", "skills directory", str(skills_dir), exc,
continuing="Continuing without skill registration.",
)
return None
return skills_dir
from . import resolve_active_skills_dir, _print_cli_warning
try:
skills_dir = resolve_active_skills_dir(self.project_root)
return resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
_print_cli_warning(
"resolve", "skills directory", None, exc,
continuing="Continuing without skill registration.",
)
return None
if skills_dir is None:
return None
opts = load_init_options(self.project_root)
if not isinstance(opts, dict):
return _ensure_usable(skills_dir)
selected_ai = opts.get("ai")
if not isinstance(selected_ai, str) or not selected_ai:
return _ensure_usable(skills_dir)
from .agents import CommandRegistrar
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai)
if agent_config and agent_config.get("extension") == "/SKILL.md":
agent_skills_dir = registrar._resolve_agent_dir(
selected_ai, agent_config, self.project_root
)
return _ensure_usable(agent_skills_dir)
return _ensure_usable(skills_dir)
def _register_extension_skills(
self,
@@ -1212,7 +1173,6 @@ class ExtensionManager:
register_commands: bool = True,
priority: int = 10,
link_commands: bool = False,
force: bool = False,
) -> ExtensionManifest:
"""Install extension from a local directory.
@@ -1223,8 +1183,6 @@ class ExtensionManager:
priority: Resolution priority (lower = higher precedence, default 10)
link_commands: If True, register rendered agent artifacts as
symlinks to a dev cache when supported by the OS.
force: If True and extension is already installed, remove it first
before proceeding with installation
Returns:
Installed extension manifest
@@ -1246,34 +1204,14 @@ class ExtensionManager:
# Check if already installed
if self.registry.is_installed(manifest.id):
if not force:
raise ExtensionError(
f"Extension '{manifest.id}' is already installed. "
f"Use 'specify extension remove {manifest.id}' first, "
f"or retry with --force to overwrite."
)
raise ExtensionError(
f"Extension '{manifest.id}' is already installed. "
f"Use 'specify extension remove {manifest.id}' first."
)
# Reject manifests that would shadow core commands or installed extensions.
self._validate_install_conflicts(manifest)
# Remove existing installation AFTER all validations pass so that a
# validation failure doesn't leave the user with a half-uninstalled
# extension (configs stranded in .backup/).
did_remove = False
if force and self.registry.is_installed(manifest.id):
# Clear any stale backup from a previous remove so that only the
# backup produced by the current remove() call is restored later.
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
# Check is_symlink first: is_dir() follows symlinks so a
# symlink-to-directory would pass, but rmtree() raises on them.
if backup_config_dir.is_symlink():
backup_config_dir.unlink()
elif backup_config_dir.is_dir():
shutil.rmtree(backup_config_dir)
elif backup_config_dir.exists():
backup_config_dir.unlink()
did_remove = self.remove(manifest.id)
# Install extension
dest_dir = self.extensions_dir / manifest.id
if dest_dir.exists():
@@ -1288,11 +1226,7 @@ class ExtensionManager:
registrar = CommandRegistrar()
# Register for all detected agents
registered_commands = registrar.register_commands_for_all_agents(
manifest,
dest_dir,
self.project_root,
link_outputs=link_commands,
create_missing_active_skills_dir=True,
manifest, dest_dir, self.project_root, link_outputs=link_commands
)
# Auto-register extension commands as agent skills when --ai-skills
@@ -1305,26 +1239,6 @@ class ExtensionManager:
hook_executor = HookExecutor(self.project_root)
hook_executor.register_hooks(manifest)
# Restore config files from backup when --force triggered a removal.
# Only restore *.yml config files to match what remove() backs up,
# so unexpected artifacts in .backup/ are not resurrected.
if did_remove:
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
# is_symlink first: is_dir() follows symlinks, but rmtree()
# raises on them — and we shouldn't follow symlinks to restore.
if backup_config_dir.is_symlink():
backup_config_dir.unlink()
elif backup_config_dir.is_dir():
for cfg_file in backup_config_dir.iterdir():
if cfg_file.is_file() and not cfg_file.is_symlink() and (
cfg_file.name.endswith("-config.yml") or
cfg_file.name.endswith("-config.local.yml")
):
shutil.copy2(cfg_file, dest_dir / cfg_file.name)
shutil.rmtree(backup_config_dir)
elif backup_config_dir.exists():
backup_config_dir.unlink()
# Update registry
self.registry.add(manifest.id, {
"version": manifest.version,
@@ -1343,7 +1257,6 @@ class ExtensionManager:
zip_path: Path,
speckit_version: str,
priority: int = 10,
force: bool = False,
) -> ExtensionManifest:
"""Install extension from ZIP file.
@@ -1351,8 +1264,6 @@ class ExtensionManager:
zip_path: Path to extension ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
force: If True and extension is already installed, remove it first
before proceeding with installation
Returns:
Installed extension manifest
@@ -1399,9 +1310,7 @@ class ExtensionManager:
raise ValidationError("No extension.yml found in ZIP file")
# Install from extracted directory
return self.install_from_directory(
extension_dir, speckit_version, priority=priority, force=force
)
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
"""Remove an installed extension.
@@ -1583,10 +1492,9 @@ class ExtensionManager:
init_options = {}
active_agent = init_options.get("ai")
ai_skills_enabled = is_ai_skills_enabled(init_options)
skills_mode_active = (
active_agent == agent_name
and ai_skills_enabled
and bool(init_options.get("ai_skills"))
and bool(agent_config)
and agent_config.get("extension") != "/SKILL.md"
)
@@ -1780,7 +1688,6 @@ class CommandRegistrar:
extension_dir: Path,
project_root: Path,
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
) -> Dict[str, List[str]]:
"""Register extension commands for all detected agents."""
context_note = f"\n<!-- Extension: {manifest.id} -->\n<!-- Config: .specify/extensions/{manifest.id}/ -->\n"
@@ -1788,7 +1695,6 @@ class CommandRegistrar:
manifest.commands, manifest.id, extension_dir, project_root,
context_note=context_note,
link_outputs=link_outputs,
create_missing_active_skills_dir=create_missing_active_skills_dir,
)
def unregister_commands(
@@ -2576,11 +2482,10 @@ class HookExecutor:
init_options = self._load_init_options()
selected_ai = init_options.get("ai")
ai_skills_enabled = is_ai_skills_enabled(init_options)
codex_skill_mode = selected_ai == "codex" and ai_skills_enabled
claude_skill_mode = selected_ai == "claude" and ai_skills_enabled
codex_skill_mode = selected_ai == "codex" and bool(init_options.get("ai_skills"))
claude_skill_mode = selected_ai == "claude" and bool(init_options.get("ai_skills"))
kimi_skill_mode = selected_ai == "kimi"
cursor_skill_mode = selected_ai == "cursor-agent" and ai_skills_enabled
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
cline_mode = selected_ai == "cline"
skill_name = self._skill_name_from_command(command_id)
@@ -2837,7 +2742,7 @@ class HookExecutor:
if not isinstance(config, dict):
config = {}
# We don't save yet, as there are no hooks to unregister,
# We don't save yet, as there are no hooks to unregister,
# but unregister_extension above might have already saved a normalized config.
return

View File

@@ -34,21 +34,6 @@ _HOOK_COMMAND_NOTE = (
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
)
_CORE_COMMAND_TEMPLATE_ORDER = (
"analyze",
"clarify",
"constitution",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",
)
_CORE_COMMAND_TEMPLATE_RANK = {
command: index for index, command in enumerate(_CORE_COMMAND_TEMPLATE_ORDER)
}
# ---------------------------------------------------------------------------
# IntegrationOption
@@ -285,16 +270,6 @@ class IntegrationBase(ABC):
)
raise NotImplementedError(msg)
# Windows: ``subprocess.run`` calls ``CreateProcess`` which does not
# consult ``PATHEXT``, so a bare command name like ``cursor-agent``
# that resolves to ``cursor-agent.cmd`` fails with ``WinError 2``.
# Resolve via ``shutil.which`` (which does honor ``PATHEXT``) so
# ``.cmd``/``.bat`` shims work transparently. On POSIX this is a
# no-op for absolute paths and a harmless lookup otherwise.
resolved = shutil.which(exec_args[0])
if resolved:
exec_args = [resolved, *exec_args[1:]]
cwd = str(project_root) if project_root else None
if stream:
@@ -370,19 +345,11 @@ class IntegrationBase(ABC):
return None
def list_command_templates(self) -> list[Path]:
"""Return ordered list of command template files from the shared directory."""
"""Return sorted list of command template files from the shared directory."""
cmd_dir = self.shared_commands_dir()
if not cmd_dir or not cmd_dir.is_dir():
return []
return sorted(
(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md"),
key=lambda f: (
_CORE_COMMAND_TEMPLATE_RANK.get(
f.stem, len(_CORE_COMMAND_TEMPLATE_ORDER)
),
f.name,
),
)
return sorted(f for f in cmd_dir.iterdir() if f.is_file() and f.suffix == ".md")
def command_filename(self, template_name: str) -> str:
"""Return the destination filename for a command template.

View File

@@ -2,12 +2,6 @@
Cursor Agent uses the ``.cursor/skills/speckit-<name>/SKILL.md`` layout.
Commands are deprecated; ``--skills`` defaults to ``True``.
The IDE/skills flow is the primary path and works without the
``cursor-agent`` CLI being installed (``requires_cli=False``). Workflow
dispatch via ``cursor-agent -p --trust --approve-mcps --force <prompt>``
is offered as an opt-in capability — the presence of ``build_exec_args()``
is what indicates dispatch support, mirroring ``CopilotIntegration``.
"""
from __future__ import annotations
@@ -21,12 +15,7 @@ class CursorAgentIntegration(SkillsIntegration):
"name": "Cursor",
"folder": ".cursor/",
"commands_subdir": "skills",
"install_url": "https://docs.cursor.com/en/cli/overview",
# IDE-first integration: ``specify init --ai cursor-agent`` must
# work without the ``cursor-agent`` CLI installed (the IDE flow
# uses skills directly). Workflow dispatch additionally requires
# the CLI on PATH, but that's enforced at dispatch time via
# ``shutil.which`` rather than as a hard ``specify init`` precheck.
"install_url": None,
"requires_cli": False,
}
registrar_config = {
@@ -39,50 +28,6 @@ class CursorAgentIntegration(SkillsIntegration):
context_file = ".cursor/rules/specify-rules.mdc"
multi_install_safe = True
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build CLI arguments for non-interactive ``cursor-agent`` execution.
Always returns argv (no ``requires_cli`` guard) so workflow
dispatch is supported even though the integration's ``config``
sets ``requires_cli=False`` to keep the IDE-only flow unblocked.
This mirrors ``CopilotIntegration``: dispatch support is signalled
by overriding ``build_exec_args()``, not by the ``requires_cli``
flag (which is reserved for the ``specify init`` precheck).
Mandatory headless flags:
* ``-p`` — print/headless mode (access to all tools)
* ``--trust`` — bypass Workspace Trust prompt (CLI exits non-zero
otherwise)
* ``--approve-mcps`` — auto-approve MCP server loading (otherwise
MCP servers stay ``not loaded (needs approval)`` and tool calls
to them are silently dropped)
* ``--force`` — auto-approve tool invocations (shell/write/MCP),
matching the implicit "trusted environment" semantics that other
integrations (``claude -p``, ``codex --exec``) get by default
Together these are the minimum set required to make
``specify workflow run speckit --input integration=cursor-agent``
behave the same way as it does for ``claude`` / ``codex``.
Verified locally: with ``--approve-mcps --force`` the agent can
call any configured MCP server (e.g. ``dingtalk-doc``) and write
files during ``/speckit-*`` skill execution; without them the run
either drops tool calls or exits non-zero on the first approval
prompt.
"""
args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt]
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
return args
@classmethod
def options(cls) -> list[IntegrationOption]:
return [

View File

@@ -29,7 +29,6 @@ from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .integrations.base import IntegrationBase
from ._init_options import is_ai_skills_enabled
def _substitute_core_template(
@@ -1263,7 +1262,7 @@ class PresetManager:
selected_ai = init_opts.get("ai")
if not isinstance(selected_ai, str):
return []
ai_skills_enabled = is_ai_skills_enabled(init_opts)
ai_skills_enabled = bool(init_opts.get("ai_skills"))
registrar = CommandRegistrar()
integration = get_integration(selected_ai)
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})

View File

@@ -449,10 +449,10 @@ class WorkflowEngine:
ValueError:
If the workflow YAML is invalid.
"""
path = Path(source).expanduser()
path = Path(source)
# Try as a direct file path first
if path.suffix.lower() in (".yml", ".yaml") and path.is_file():
if path.suffix in (".yml", ".yaml") and path.exists():
return WorkflowDefinition.from_yaml(path)
# Try as an installed workflow ID

View File

@@ -121,11 +121,6 @@ class TestBasePrimitives:
assert len(templates) > 0
assert all(t.suffix == ".md" for t in templates)
def test_list_command_templates_keeps_checklist_after_plan(self):
i = StubIntegration()
stems = [template.stem for template in i.list_command_templates()]
assert stems.index("plan") < stems.index("checklist")
def test_command_filename_default(self):
i = StubIntegration()
assert i.command_filename("plan") == "speckit.plan.md"

View File

@@ -254,8 +254,8 @@ class MarkdownIntegrationTests:
COMMAND_STEMS = [
"agent-context.update",
"analyze", "clarify", "constitution", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:

View File

@@ -100,8 +100,8 @@ class SkillsIntegrationTests:
skill_files = [f for f in created if "scripts" not in f.parts]
expected_commands = {
"analyze", "clarify", "constitution", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
}
# Derive command names from the skill directory names
@@ -393,8 +393,8 @@ class SkillsIntegrationTests:
# -- Complete file inventory ------------------------------------------
_SKILL_COMMANDS = [
"analyze", "clarify", "constitution", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
]
def _expected_files(self, script_variant: str) -> list[str]:

View File

@@ -486,11 +486,11 @@ class TomlIntegrationTests:
COMMAND_STEMS = [
"agent-context.update",
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",

View File

@@ -365,11 +365,11 @@ class YamlIntegrationTests:
COMMAND_STEMS = [
"agent-context.update",
"analyze",
"checklist",
"clarify",
"constitution",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",

View File

@@ -127,8 +127,8 @@ class TestCopilotIntegration:
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
assert len(agent_files) == 9
expected_commands = {
"analyze", "clarify", "constitution", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
}
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files}
assert actual_commands == expected_commands
@@ -321,8 +321,8 @@ class TestCopilotSkillsMode:
"""Tests for Copilot integration in --skills mode."""
_SKILL_COMMANDS = [
"analyze", "clarify", "constitution", "implement",
"plan", "checklist", "specify", "tasks", "taskstoissues",
"analyze", "checklist", "clarify", "constitution",
"implement", "plan", "specify", "tasks", "taskstoissues",
]
def _make_copilot(self):

View File

@@ -1,7 +1,6 @@
"""Tests for CursorAgentIntegration."""
from pathlib import Path
from urllib.parse import urlparse
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
@@ -107,157 +106,3 @@ class TestCursorAgentAutoPromote:
assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}"
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
class TestCursorAgentCliDispatch:
"""Verify the CLI dispatch path for cursor-agent (issue #2629).
The ``cursor-agent`` CLI supports headless execution via ``-p`` (with
full tool access including write/shell) and requires ``--trust`` to
bypass the Workspace Trust prompt. These tests pin the exact argv
shape that the workflow runner will use.
"""
def test_requires_cli_is_false_for_ide_first_flow(self):
"""``requires_cli`` must stay False so the IDE-only flow keeps working.
``specify init --ai cursor-agent`` (without ``--ignore-agent-tools``)
treats ``requires_cli=True`` as a hard precheck and fails when the
``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE
/ skills flow can run without it. Workflow dispatch support is
signalled by overriding ``build_exec_args()`` instead, mirroring
``CopilotIntegration``.
"""
i = get_integration("cursor-agent")
assert i.config.get("requires_cli") is False
def test_install_url_is_set(self):
i = get_integration("cursor-agent")
url = i.config.get("install_url")
assert url is not None
# CodeQL: use a hostname comparison instead of a substring check
# to avoid the "Incomplete URL substring sanitization" warning
# (substring "cursor.com" can also appear in attacker-controlled
# positions of an arbitrary URL).
host = (urlparse(url).hostname or "").lower()
assert host == "cursor.com" or host.endswith(".cursor.com")
def test_build_exec_args_default_includes_headless_flags_and_json(self):
"""Default argv emits the full headless flag set: -p --trust
--approve-mcps --force, then prompt, then --output-format json.
"""
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-specify some-feature")
assert args == [
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
"/speckit-specify some-feature",
"--output-format", "json",
]
def test_build_exec_args_text_output_omits_format(self):
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-plan", output_json=False)
assert args == [
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
"/speckit-plan",
]
def test_build_exec_args_with_model(self):
i = get_integration("cursor-agent")
args = i.build_exec_args(
"/speckit-specify", model="sonnet-4-thinking", output_json=False
)
assert args == [
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
"/speckit-specify",
"--model", "sonnet-4-thinking",
]
def test_build_exec_args_contains_mandatory_headless_flags(self):
"""The four headless flags must always appear together.
``--approve-mcps`` is required so MCP servers (e.g. dingtalk-doc)
actually load in headless mode; ``--force`` is required so the
agent doesn't block on tool-call approval prompts during the
speckit workflow. Together with ``-p`` and ``--trust`` they
bring cursor-agent's headless behaviour in line with
``claude -p`` / ``codex --exec`` from spec-kit's perspective.
"""
i = get_integration("cursor-agent")
args = i.build_exec_args("/speckit-implement", output_json=False)
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
assert flag in args, f"missing mandatory headless flag: {flag}"
def test_build_exec_args_supports_dispatch_without_requires_cli(self):
"""``build_exec_args`` must return argv even though ``requires_cli``
is ``False``.
``CursorAgentIntegration`` opts out of the ``requires_cli`` hard
precheck (so ``specify init`` doesn't fail when the CLI isn't on
PATH) but still supports workflow dispatch. The presence of a
non-``None`` argv from ``build_exec_args()`` is what the engine
keys off — pin that invariant.
"""
i = get_integration("cursor-agent")
assert i.config.get("requires_cli") is False
argv = i.build_exec_args("/speckit-plan", output_json=False)
assert argv is not None
assert argv[0] == "cursor-agent"
def test_build_command_invocation_uses_hyphenated_skill_name(self):
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
i = get_integration("cursor-agent")
assert i.build_command_invocation("speckit.plan", "feature-x") == "/speckit-plan feature-x"
assert i.build_command_invocation("plan") == "/speckit-plan"
def test_dispatch_command_resolves_cmd_shim_for_subprocess(self):
"""``.cmd`` shims must be resolved to their full path before ``subprocess.run``.
``cursor-agent`` (and other npm-installed CLIs on Windows) ship as
``cursor-agent.cmd`` wrappers. ``shutil.which`` honors ``PATHEXT``
and finds them, but Python's ``subprocess.run`` calls
``CreateProcess`` which does **not** consult ``PATHEXT`` and fails
with ``WinError 2`` on a bare ``["cursor-agent", ...]`` argv. The
fix in ``base.py::dispatch_command`` resolves ``exec_args[0]`` via
``shutil.which`` so the full ``.cmd`` path is what reaches
``CreateProcess``.
"""
from unittest.mock import patch, MagicMock
i = get_integration("cursor-agent")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "ok"
mock_result.stderr = ""
fake_path = r"C:\Users\foo\AppData\Local\cursor-agent\cursor-agent.CMD"
with patch(
"specify_cli.integrations.base.shutil.which", return_value=fake_path
), patch("subprocess.run", return_value=mock_result) as mock_run:
result = i.dispatch_command(
"speckit.plan", args="feature-x", stream=False, timeout=5
)
assert result["exit_code"] == 0
argv = mock_run.call_args[0][0]
assert argv[0] == fake_path, f"expected resolved .CMD path, got: {argv[0]!r}"
assert argv[1:6] == ["-p", "--trust", "--approve-mcps", "--force", "/speckit-plan feature-x"]
def test_dispatch_command_passthrough_when_shutil_which_finds_nothing(self):
"""If ``shutil.which`` returns ``None``, leave argv unchanged so the
existing ``FileNotFoundError`` path remains observable to callers."""
from unittest.mock import patch, MagicMock
i = get_integration("cursor-agent")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
with patch(
"specify_cli.integrations.base.shutil.which", return_value=None
), patch("subprocess.run", return_value=mock_result) as mock_run:
i.dispatch_command("speckit.plan", stream=False, timeout=5)
argv = mock_run.call_args[0][0]
assert argv[0] == "cursor-agent"

View File

@@ -213,10 +213,10 @@ class TestGenericIntegration:
"command_stem",
[
"analyze",
"checklist",
"clarify",
"implement",
"plan",
"checklist",
"specify",
"tasks",
"taskstoissues",

View File

@@ -17,7 +17,6 @@ import tempfile
import shutil
import yaml
from pathlib import Path
from typing import Any
from specify_cli.extensions import (
ExtensionManifest,
@@ -27,9 +26,7 @@ from specify_cli.extensions import (
# ===== Helpers =====
def _create_init_options(
project_root: Path, ai: str = "claude", ai_skills: Any = True
):
def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True):
"""Write a .specify/init-options.json file."""
opts_dir = project_root / ".specify"
opts_dir.mkdir(parents=True, exist_ok=True)
@@ -38,7 +35,7 @@ def _create_init_options(
"ai": ai,
"ai_skills": ai_skills,
"script": "sh",
}), encoding="utf-8")
}))
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
@@ -223,20 +220,11 @@ class TestExtensionManagerGetSkillsDir:
result = manager._get_skills_dir()
assert result == skills_dir
def test_returns_none_when_ai_skills_is_non_boolean_truthy(self, project_dir):
"""Corrupted truthy ai_skills values should not enable skills mode."""
_create_init_options(project_dir, ai="claude", ai_skills="false")
manager = ExtensionManager(project_dir)
result = manager._get_skills_dir()
assert result is None
assert not (project_dir / ".claude" / "skills").exists()
def test_returns_none_for_non_dict_init_options(self, project_dir):
"""Corrupted-but-parseable init-options should not crash skill-dir lookup."""
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_text("[]", encoding="utf-8")
opts_file.write_text("[]")
_create_skills_dir(project_dir, ai="claude")
manager = ExtensionManager(project_dir)
result = manager._get_skills_dir()
@@ -667,393 +655,6 @@ class TestExtensionSkillRegistration:
assert "speckit-early-ext-hello" in metadata["registered_skills"]
assert "speckit-early-ext-world" in metadata["registered_skills"]
def test_commands_registered_when_claude_skills_dir_missing(self, project_dir, temp_dir):
"""Extension install should not silently skip Claude when skills dir is missing."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
(project_dir / ".claude").mkdir()
# Deliberately do NOT create .claude/skills
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
skills_dir = project_dir / ".claude" / "skills"
assert skills_dir.is_dir()
metadata = manager.registry.get(manifest.id)
assert metadata["registered_commands"] == {
"claude": [
"speckit.early-ext.hello",
"speckit.early-ext.world",
]
}
assert metadata["registered_skills"] == []
skill_file = skills_dir / "speckit-early-ext-hello" / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text(encoding="utf-8")
assert "source: early-ext:commands/hello.md" in content
def test_hermes_global_skills_dir_used_when_marker_is_recovered(
self, project_dir, temp_dir, monkeypatch
):
"""Hermes recovery must not use the project marker as the output dir."""
home = temp_dir / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: home)
_create_init_options(project_dir, ai="hermes", ai_skills=True)
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {
"hermes": [
"speckit.early-ext.hello",
"speckit.early-ext.world",
]
}
assert metadata["registered_skills"] == []
global_skills_dir = home / ".hermes" / "skills"
assert (
global_skills_dir / "speckit-early-ext-hello" / "SKILL.md"
).exists()
assert (
global_skills_dir / "speckit-early-ext-world" / "SKILL.md"
).exists()
marker = project_dir / ".hermes" / "skills"
assert marker.is_dir()
assert list(marker.glob("speckit-*/SKILL.md")) == []
def test_hermes_get_skills_dir_creates_global_output_dir(
self, project_dir, temp_dir, monkeypatch
):
"""ExtensionManager should create the agent-specific output dir it returns."""
home = temp_dir / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: home)
_create_init_options(project_dir, ai="hermes", ai_skills=True)
manager = ExtensionManager(project_dir)
skills_dir = manager._get_skills_dir()
assert skills_dir == home / ".hermes" / "skills"
assert skills_dir.is_dir()
assert (project_dir / ".hermes" / "skills").is_dir()
def test_unusable_hermes_global_skills_dir_skips_skill_registration(
self, project_dir, temp_dir, monkeypatch, capsys
):
"""An unusable agent-specific output dir should warn and skip skills."""
home = temp_dir / "home"
hermes_dir = home / ".hermes"
hermes_dir.mkdir(parents=True)
(hermes_dir / "skills").write_text("not a directory", encoding="utf-8")
monkeypatch.setattr(Path, "home", lambda: home)
_create_init_options(project_dir, ai="hermes", ai_skills=True)
ext_dir = _create_extension_dir(temp_dir, ext_id="blocked-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=False
)
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_skills"] == []
captured = capsys.readouterr()
assert "Warning:" in captured.out
assert "Continuing without skill registration." in captured.out
def test_detect_dir_marker_file_does_not_register_hermes_commands(
self, project_dir, temp_dir, monkeypatch
):
"""Regular files at detect_dir marker paths should not detect agents."""
home = temp_dir / "home"
global_skills_dir = home / ".hermes" / "skills"
global_skills_dir.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: home)
_create_init_options(project_dir, ai="hermes", ai_skills=True)
marker_parent = project_dir / ".hermes"
marker_parent.mkdir()
marker_file = marker_parent / "skills"
marker_file.write_text("not a directory", encoding="utf-8")
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
assert marker_file.is_file()
assert marker_file.read_text(encoding="utf-8") == "not a directory"
assert not (
global_skills_dir / "speckit-early-ext-hello" / "SKILL.md"
).exists()
assert not (
global_skills_dir / "speckit-early-ext-world" / "SKILL.md"
).exists()
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {}
assert metadata["registered_skills"] == []
def test_non_boolean_ai_skills_does_not_recover_missing_skills_dir(
self, project_dir, temp_dir
):
"""Corrupted truthy ai_skills values should not recover skills dirs."""
_create_init_options(project_dir, ai="claude", ai_skills="false")
(project_dir / ".claude").mkdir()
# Deliberately do NOT create .claude/skills.
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {}
assert metadata["registered_skills"] == []
assert not (project_dir / ".claude" / "skills").exists()
def test_non_boolean_ai_skills_does_not_skip_default_agent_reregistration(
self, project_dir, temp_dir
):
"""Corrupted ai_skills values should not trigger skills-mode skips."""
_create_init_options(project_dir, ai="copilot", ai_skills="false")
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=False
)
manager.register_enabled_extensions_for_agent("copilot")
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {
"copilot": [
"speckit.early-ext.hello",
"speckit.early-ext.world",
]
}
assert metadata["registered_skills"] == []
assert (project_dir / ".github" / "agents").is_dir()
def test_existing_agent_command_path_file_is_not_detected(
self, project_dir, temp_dir
):
"""Existing files at command-dir paths should not count as detected agents."""
_create_init_options(project_dir, ai="claude", ai_skills=False)
claude_dir = project_dir / ".claude"
claude_dir.mkdir()
skills_file = claude_dir / "skills"
skills_file.write_text("not a directory", encoding="utf-8")
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
assert skills_file.read_text(encoding="utf-8") == "not a directory"
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {}
assert metadata["registered_skills"] == []
def test_missing_shared_skills_dir_registers_only_active_agent(self, project_dir, temp_dir):
"""Recreating shared skills dirs should not activate unrelated agents."""
_create_init_options(project_dir, ai="agy", ai_skills=True)
(project_dir / ".agents").mkdir()
# Deliberately do NOT create .agents/skills, shared by agy and codex.
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
skills_dir = project_dir / ".agents" / "skills"
assert skills_dir.is_dir()
metadata = manager.registry.get(manifest.id)
assert metadata["registered_commands"] == {
"agy": [
"speckit.early-ext.hello",
"speckit.early-ext.world",
]
}
assert metadata["registered_skills"] == []
def test_missing_shared_skills_dir_uses_normalized_guard_for_later_agents(
self, project_dir, temp_dir, monkeypatch
):
"""Shared-dir suppression should tolerate lexical path differences."""
_create_init_options(project_dir, ai="agy", ai_skills=True)
(project_dir / ".agents").mkdir()
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
from specify_cli.agents import CommandRegistrar as AgentRegistrar
original_resolve_agent_dir = AgentRegistrar._resolve_agent_dir
original_register_commands = AgentRegistrar.register_commands
attempted_agents = []
def resolve_codex_with_parent_segment(self, agent_name, agent_config, root):
if agent_name == "codex":
return root / ".agents" / ".." / ".agents" / "skills"
return original_resolve_agent_dir(agent_name, agent_config, root)
def record_registration(self, agent_name, *args, **kwargs):
attempted_agents.append(agent_name)
return original_register_commands(self, agent_name, *args, **kwargs)
monkeypatch.setattr(
AgentRegistrar, "_resolve_agent_dir", resolve_codex_with_parent_segment
)
monkeypatch.setattr(AgentRegistrar, "register_commands", record_registration)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
assert attempted_agents == ["agy"]
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {
"agy": [
"speckit.early-ext.hello",
"speckit.early-ext.world",
]
}
assert metadata["registered_skills"] == []
def test_missing_shared_skills_dir_write_oserror_does_not_register_other_agents(
self, project_dir, temp_dir, monkeypatch
):
"""Failed active registration must not make shared skills dirs detected."""
_create_init_options(project_dir, ai="agy", ai_skills=True)
(project_dir / ".agents").mkdir()
# Deliberately do NOT create .agents/skills, shared by agy and codex.
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
from specify_cli.agents import CommandRegistrar as AgentRegistrar
original_register_commands = AgentRegistrar.register_commands
attempted_agents = []
def fail_recovered_agy_registration(self, agent_name, *args, **kwargs):
attempted_agents.append(agent_name)
if agent_name == "agy":
raise PermissionError("denied")
return original_register_commands(self, agent_name, *args, **kwargs)
monkeypatch.setattr(
AgentRegistrar, "register_commands", fail_recovered_agy_registration
)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
skills_dir = project_dir / ".agents" / "skills"
assert skills_dir.is_dir()
assert attempted_agents == ["agy"]
metadata = manager.registry.get(manifest.id)
assert metadata is not None
assert metadata["registered_commands"] == {}
assert "speckit-early-ext-hello" in metadata["registered_skills"]
assert "speckit-early-ext-world" in metadata["registered_skills"]
def test_missing_active_skills_dir_does_not_follow_symlinked_parent(
self, project_dir, temp_dir
):
"""Recovered command registration must reuse active skills-dir safety checks."""
if not hasattr(os, "symlink"):
pytest.skip("symlinks are unavailable")
_create_init_options(project_dir, ai="claude", ai_skills=True)
outside = temp_dir / "outside-claude"
outside.mkdir()
try:
os.symlink(outside, project_dir / ".claude", target_is_directory=True)
except OSError:
pytest.skip("Current platform/user cannot create directory symlinks")
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
metadata = manager.registry.get(manifest.id)
assert metadata["registered_commands"] == {}
assert metadata["registered_skills"] == []
assert not (outside / "skills").exists()
def test_missing_active_skills_dir_invalid_parent_skips_without_aborting(
self, project_dir, temp_dir
):
"""Invalid active skill parents should not abort extension installation."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
(project_dir / ".claude").write_text("not a directory", encoding="utf-8")
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
metadata = manager.registry.get(manifest.id)
assert metadata["registered_commands"] == {}
assert metadata["registered_skills"] == []
def test_missing_active_skills_dir_write_oserror_skips_without_aborting(
self, project_dir, temp_dir, monkeypatch
):
"""Filesystem failures in recovered command registration should skip safely."""
_create_init_options(project_dir, ai="claude", ai_skills=True)
(project_dir / ".claude").mkdir()
ext_dir = _create_extension_dir(temp_dir, ext_id="early-ext")
from specify_cli.agents import CommandRegistrar as AgentRegistrar
original_register_commands = AgentRegistrar.register_commands
def fail_recovered_claude_registration(self, agent_name, *args, **kwargs):
if agent_name == "claude":
raise PermissionError("denied")
return original_register_commands(self, agent_name, *args, **kwargs)
monkeypatch.setattr(
AgentRegistrar, "register_commands", fail_recovered_claude_registration
)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
ext_dir, "0.1.0", register_commands=True
)
metadata = manager.registry.get(manifest.id)
assert metadata["registered_commands"] == {}
assert "speckit-early-ext-hello" in metadata["registered_skills"]
assert "speckit-early-ext-world" in metadata["registered_skills"]
# ===== Extension Skill Unregistration Tests =====
@@ -1137,7 +738,7 @@ class TestExtensionSkillEdgeCases:
"""Corrupted init-options payloads should disable skill registration, not crash install."""
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_text("[]", encoding="utf-8")
opts_file.write_text("[]")
_create_skills_dir(project_dir, ai="claude")
manager = ExtensionManager(project_dir)

View File

@@ -782,71 +782,6 @@ class TestExtensionManager:
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "commands" / "hello.md").exists()
def test_install_from_directory_explicitly_recovers_active_skills_dir(
self, extension_dir, project_dir, monkeypatch
):
"""Extension install should explicitly request active skills-dir recovery."""
captured = {}
def fake_register_all(
self,
manifest,
extension_dir,
project_root,
link_outputs=False,
create_missing_active_skills_dir=False,
):
captured["create_missing_active_skills_dir"] = (
create_missing_active_skills_dir
)
return {}
monkeypatch.setattr(
CommandRegistrar,
"register_commands_for_all_agents",
fake_register_all,
)
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=True)
assert captured["create_missing_active_skills_dir"] is True
def test_command_registrar_default_does_not_recover_active_skills_dir(
self, extension_dir, project_dir, monkeypatch
):
"""The extension wrapper should preserve the core registrar's conservative default."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
captured = {}
def fake_register_all(
self,
commands,
source_id,
source_dir,
project_root,
context_note=None,
link_outputs=False,
create_missing_active_skills_dir=False,
):
captured["create_missing_active_skills_dir"] = (
create_missing_active_skills_dir
)
return {}
monkeypatch.setattr(
AgentCommandRegistrar,
"register_commands_for_all_agents",
fake_register_all,
)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_all_agents(manifest, extension_dir, project_dir)
assert captured["create_missing_active_skills_dir"] is False
def test_install_duplicate(self, extension_dir, project_dir):
"""Test installing already installed extension."""
manager = ExtensionManager(project_dir)
@@ -858,102 +793,6 @@ class TestExtensionManager:
with pytest.raises(ExtensionError, match="already installed"):
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
def test_install_force_reinstall(self, extension_dir, project_dir):
"""Test force-reinstalling an already-installed extension."""
manager = ExtensionManager(project_dir)
# Install once
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
assert manager.registry.is_installed("test-ext")
# Force-reinstall
manifest2 = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, force=True
)
assert manifest2.id == "test-ext"
assert manager.registry.is_installed("test-ext")
# Check extension directory was recreated
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert ext_dir.exists()
assert (ext_dir / "extension.yml").exists()
assert (ext_dir / "commands" / "hello.md").exists()
def test_install_force_config_preserved(self, extension_dir, project_dir):
"""Test that config files are preserved when force-reinstalling."""
manager = ExtensionManager(project_dir)
# Install once
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False
)
# Create a config file in the installed extension directory
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
config_file = ext_dir / "test-ext-config.yml"
config_file.write_text("test: config")
# Force-reinstall
manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, force=True
)
# Config file should still exist after reinstall
new_config = ext_dir / "test-ext-config.yml"
assert new_config.exists()
assert new_config.read_text() == "test: config"
def test_install_force_without_existing(self, extension_dir, project_dir):
"""Test force-install when extension is NOT already installed (works normally)."""
manager = ExtensionManager(project_dir)
manifest = manager.install_from_directory(
extension_dir, "0.1.0", register_commands=False, force=True
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_zip_force_reinstall(self, extension_dir, project_dir):
"""Test force-reinstalling from ZIP when already installed."""
import zipfile
import tempfile
manager = ExtensionManager(project_dir)
# Install once from directory
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
# Create a ZIP of the extension in a temp directory (not NamedTemporaryFile,
# which can fail on Windows due to file locking).
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "test-ext.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for f in extension_dir.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(extension_dir))
# Force-reinstall from ZIP
manifest = manager.install_from_zip(
zip_path, "0.1.0", force=True
)
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert ext_dir.exists()
def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir):
"""Test that duplicate install error message suggests --force."""
manager = ExtensionManager(project_dir)
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
with pytest.raises(ExtensionError, match="--force"):
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
def test_install_rejects_extension_id_in_core_namespace(self, temp_dir, project_dir):
"""Install should reject extension IDs that shadow core commands."""
import yaml
@@ -4949,26 +4788,6 @@ class TestHookInvocationRendering:
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "$speckit-tasks"
def test_non_boolean_ai_skills_keeps_default_hook_invocation(self, project_dir):
"""Corrupted truthy ai_skills values should not enable skill invocation."""
init_options = project_dir / ".specify" / "init-options.json"
init_options.parent.mkdir(parents=True, exist_ok=True)
init_options.write_text(
json.dumps({"ai": "codex", "ai_skills": "false"}), encoding="utf-8"
)
hook_executor = HookExecutor(project_dir)
execution = hook_executor.execute_hook(
{
"extension": "test-ext",
"command": "speckit.tasks",
"optional": False,
}
)
assert execution["command"] == "speckit.tasks"
assert execution["invocation"] == "/speckit.tasks"
def test_cline_hooks_render_hyphenated_invocation(self, project_dir):
"""Cline projects should render /speckit-* invocations."""
init_options = project_dir / ".specify" / "init-options.json"
@@ -5295,69 +5114,3 @@ $ARGUMENTS
# Verify body references are still dotted for non-Cline
assert "speckit.mock-ext.greet" in hello_body
assert "speckit-mock-ext-greet" not in hello_body
class TestExtensionForceCLI:
"""CLI tests for `specify extension add --dev --force`."""
def _create_minimal_extension(self, base_dir: str | Path, ext_id: str = "test-ext") -> Path:
"""Create a minimal extension directory with manifest."""
import yaml
ext_dir = Path(base_dir) / ext_id
ext_dir.mkdir(parents=True, exist_ok=True)
(ext_dir / "commands").mkdir()
manifest = {
"schema_version": "1.0",
"extension": {
"id": ext_id,
"name": "Test Extension",
"version": "1.0.0",
"description": "Test",
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": f"speckit.{ext_id}.hello",
"file": "commands/hello.md",
"description": "Test command",
}
]
},
}
(ext_dir / "extension.yml").write_text(yaml.dump(manifest))
(ext_dir / "commands" / "hello.md").write_text(
"---\ndescription: Test\n---\n\nHello $ARGUMENTS\n"
)
return ext_dir
def test_add_dev_force_reinstall(self, tmp_path):
"""extension add --dev --force should reinstall without error."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
ext_src = self._create_minimal_extension(tmp_path)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
# First install
result1 = runner.invoke(
app, ["extension", "add", str(ext_src), "--dev"], catch_exceptions=False
)
assert result1.exit_code == 0, strip_ansi(result1.output)
assert "installed" in strip_ansi(result1.output)
# Force reinstall
result2 = runner.invoke(
app, ["extension", "add", str(ext_src), "--dev", "--force"], catch_exceptions=False
)
assert result2.exit_code == 0, strip_ansi(result2.output)
assert "installed" in strip_ansi(result2.output)

View File

@@ -2255,51 +2255,6 @@ class TestInitOptions:
assert loaded["ai"] == "claude"
assert loaded["ai_skills"] is True
def test_save_and_load_available_from_init_options_module(self, project_dir):
from specify_cli._init_options import load_init_options, save_init_options
opts = {"ai": "codex", "ai_skills": True, "script": "sh"}
save_init_options(project_dir, opts)
assert load_init_options(project_dir) == opts
def test_save_uses_utf8_encoding(self, project_dir, monkeypatch):
from specify_cli import save_init_options
original_write_text = Path.write_text
seen: dict[str, str | None] = {}
def spy_write_text(path, data, *args, **kwargs):
if path == project_dir / ".specify" / "init-options.json":
seen["encoding"] = kwargs.get("encoding")
return original_write_text(path, data, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", spy_write_text)
save_init_options(project_dir, {"label": "中文测试"})
assert seen["encoding"] == "utf-8"
def test_load_uses_utf8_encoding(self, project_dir, monkeypatch):
from specify_cli import load_init_options
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_text('{"ai": "codex"}', encoding="utf-8")
original_read_text = Path.read_text
seen: dict[str, str | None] = {}
def spy_read_text(path, *args, **kwargs):
if path == opts_file:
seen["encoding"] = kwargs.get("encoding")
return original_read_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", spy_read_text)
assert load_init_options(project_dir) == {"ai": "codex"}
assert seen["encoding"] == "utf-8"
def test_load_returns_empty_when_missing(self, project_dir):
from specify_cli import load_init_options
@@ -2393,51 +2348,6 @@ class TestInitOptions:
assert load_init_options(project_dir) == {}
@pytest.mark.parametrize("payload", ["[]", '"value"', "42", "true", "null"])
def test_load_returns_empty_on_non_object_json(self, project_dir, payload):
from specify_cli import load_init_options
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_text(payload, encoding="utf-8")
assert load_init_options(project_dir) == {}
def test_load_returns_empty_on_unicode_decode_error(self, project_dir, monkeypatch):
from specify_cli import load_init_options
opts_file = project_dir / ".specify" / "init-options.json"
opts_file.parent.mkdir(parents=True, exist_ok=True)
opts_file.write_bytes(b"{}")
original_read_text = Path.read_text
def raise_decode_error(path, *args, **kwargs):
if path == opts_file:
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
return original_read_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", raise_decode_error)
assert load_init_options(project_dir) == {}
@pytest.mark.parametrize(
("value", "expected"),
[
(True, True),
(False, False),
("true", False),
("false", False),
(1, False),
(0, False),
(None, False),
],
)
def test_is_ai_skills_enabled_requires_boolean_true(self, value, expected):
from specify_cli._init_options import is_ai_skills_enabled
assert is_ai_skills_enabled({"ai_skills": value}) is expected
class TestPresetSkills:
"""Tests for preset skill registration and unregistration.

View File

@@ -1,238 +0,0 @@
"""Tests for running workflow YAML files without a project."""
import os
import pytest
import yaml
class TestWorkflowRunWithoutProject:
"""Tests that specify workflow run works with YAML files without .specify/ dir."""
def test_workflow_run_yaml_without_project(self, tmp_path):
"""Running a .yml file should work without a .specify/ directory."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
# Create a minimal workflow YAML with a shell step
workflow_file = tmp_path / "test-workflow.yml"
workflow_content = {
"schema_version": "1.0",
"workflow": {
"id": "standalone-test",
"name": "Standalone Test",
"version": "1.0.0",
"description": "A workflow that runs without a project",
},
"steps": [
{
"id": "create-marker",
"type": "shell",
"run": "echo done > marker.txt",
},
],
}
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", str(workflow_file),
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"workflow run failed: {result.output}"
assert "completed" in result.output
assert (tmp_path / "marker.txt").exists()
assert (tmp_path / ".specify" / "workflows" / "runs").is_dir()
def test_workflow_run_yaml_with_tilde_and_uppercase_suffix(self, tmp_path, monkeypatch):
"""Running ~/file.YML should work without a .specify/ directory."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
home_dir = tmp_path / "home"
home_dir.mkdir()
monkeypatch.setenv("HOME", str(home_dir))
monkeypatch.setenv("USERPROFILE", str(home_dir))
workflow_file = home_dir / "test-workflow.YML"
workflow_content = {
"schema_version": "1.0",
"workflow": {
"id": "standalone-test-uppercase",
"name": "Standalone Test Uppercase",
"version": "1.0.0",
"description": "A workflow that runs from ~/ with an uppercase suffix",
},
"steps": [
{
"id": "create-marker",
"type": "shell",
"run": "echo done > marker.txt",
},
],
}
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", "~/test-workflow.YML",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"workflow run failed: {result.output}"
assert "Status: completed" in result.output
assert (tmp_path / "marker.txt").exists()
def test_workflow_run_id_still_requires_project(self, tmp_path):
"""Running a workflow by ID should still require a .specify/ directory."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", "some-workflow-id",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Not a spec-kit project" in result.output
def test_workflow_run_missing_yaml_file(self, tmp_path):
"""Running a non-existent .yml file should still require a project."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", "nonexistent.yml",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
# non-existent .yml files fall through to project check or file-not-found
assert result.exit_code != 0
def test_workflow_run_failing_yaml_without_project(self, tmp_path):
"""A failing workflow YAML should report failure status."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
workflow_file = tmp_path / "fail-workflow.yml"
workflow_content = {
"schema_version": "1.0",
"workflow": {
"id": "fail-test",
"name": "Fail Test",
"version": "1.0.0",
"description": "A workflow that fails",
},
"steps": [
{
"id": "fail-step",
"type": "shell",
"run": "exit 1",
},
],
}
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", str(workflow_file),
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, f"workflow run failed unexpectedly: {result.output}"
assert "Status: failed" in result.output
def test_workflow_run_yaml_rejects_symlinked_specify_dir(self, tmp_path):
"""Running local YAML should fail when .specify is a symlink."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
workflow_file = tmp_path / "test-workflow.yml"
workflow_content = {
"schema_version": "1.0",
"workflow": {
"id": "symlink-test",
"name": "Symlink Test",
"version": "1.0.0",
"description": "A workflow for symlink guard testing",
},
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
}
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
target_dir = tmp_path / "real-specify-dir"
target_dir.mkdir()
try:
(tmp_path / ".specify").symlink_to(target_dir, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("Symlinks are not available in this environment")
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", str(workflow_file),
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Refusing to use symlinked .specify path in current directory" in result.output
def test_workflow_run_yaml_rejects_non_directory_specify_path(self, tmp_path):
"""Running local YAML should fail when .specify is not a directory."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
workflow_file = tmp_path / "test-workflow.yml"
workflow_content = {
"schema_version": "1.0",
"workflow": {
"id": "nondir-test",
"name": "Non-directory Test",
"version": "1.0.0",
"description": "A workflow for non-directory guard testing",
},
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
}
workflow_file.write_text(yaml.dump(workflow_content), encoding="utf-8")
(tmp_path / ".specify").write_text("not a directory", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, [
"workflow", "run", str(workflow_file),
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert ".specify path exists but is not a directory" in result.output

View File

@@ -601,18 +601,15 @@ class TestCommandStep:
mock_result.stderr = ""
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
patch("subprocess.run", return_value=mock_result) as mock_run:
result = step.execute(config, ctx)
assert result.status == StepStatus.COMPLETED
assert result.output["dispatched"] is True
assert result.output["exit_code"] == 0
# Verify the CLI was called with the resolved path (via shutil.which,
# which honors PATHEXT for ``.cmd``/``.bat`` shims on Windows), then
# ``-p`` and the skill invocation.
# Verify the CLI was called with -p and the skill invocation
call_args = mock_run.call_args
assert call_args[0][0][0] == "/usr/local/bin/claude"
assert call_args[0][0][0] == "claude"
assert call_args[0][0][1] == "-p"
# Claude is a SkillsIntegration so uses /speckit-specify
assert "/speckit-specify login" in call_args[0][0][2]
@@ -641,7 +638,6 @@ class TestCommandStep:
mock_result.stderr = "API error"
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
patch("subprocess.run", return_value=mock_result):
result = step.execute(config, ctx)
@@ -3138,158 +3134,6 @@ steps:
assert "do-specify" not in state.step_results
class TestWorkflowJsonOutput:
"""Test the --json machine-readable output for run/resume/status."""
_WF = """
schema_version: "1.0"
workflow:
id: "json-wf"
name: "JSON WF"
version: "1.0.0"
steps:
- id: ask
type: gate
message: "Review"
options: [approve, reject]
- id: after
type: shell
run: "echo done"
"""
_WF_DONE = """
schema_version: "1.0"
workflow:
id: "json-done"
name: "JSON Done"
version: "1.0.0"
steps:
- id: only
type: shell
run: "echo done"
"""
def _write_wf(self, project_dir, text, name):
path = project_dir / f"{name}.yml"
path.write_text(text, encoding="utf-8")
return path
def _invoke(self, project_dir, args):
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
return runner.invoke(app, args, catch_exceptions=False)
def test_run_json_completed(self, project_dir):
wf = self._write_wf(project_dir, self._WF_DONE, "done")
result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["workflow_id"] == "json-done"
assert payload["status"] == "completed"
assert "run_id" in payload
def test_run_json_paused(self, project_dir):
wf = self._write_wf(project_dir, self._WF, "gated")
result = self._invoke(project_dir, ["workflow", "run", str(wf), "--json"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["status"] == "paused"
assert payload["current_step_id"] == "ask"
assert payload["current_step_index"] == 0
def test_run_json_output_has_no_markup_or_ansi(self, project_dir):
wf = self._write_wf(project_dir, self._WF_DONE, "clean")
out = self._invoke(
project_dir, ["workflow", "run", str(wf), "--json"]
).stdout
# Machine output must be exactly the JSON object: no Rich markup
# tags and no ANSI escape sequences leaking in.
assert "\x1b[" not in out
assert "[/" not in out
assert out.strip() == json.dumps(json.loads(out), indent=2)
def test_run_default_output_is_human_not_json(self, project_dir):
wf = self._write_wf(project_dir, self._WF_DONE, "done2")
result = self._invoke(project_dir, ["workflow", "run", str(wf)])
assert result.exit_code == 0
assert "Running workflow" in result.stdout
with pytest.raises(json.JSONDecodeError):
json.loads(result.stdout)
def test_status_json_single_and_list(self, project_dir):
wf = self._write_wf(project_dir, self._WF, "gated2")
run = json.loads(
self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout
)
rid = run["run_id"]
single = json.loads(
self._invoke(project_dir, ["workflow", "status", rid, "--json"]).stdout
)
assert single["run_id"] == rid
assert single["status"] == "paused"
assert single["steps"]["ask"] == "paused"
# status --json carries the same step-position fields as run/resume
# so automation never has to branch on which command produced it.
assert single["current_step_id"] == run["current_step_id"]
assert single["current_step_index"] == run["current_step_index"]
listing = json.loads(
self._invoke(project_dir, ["workflow", "status", "--json"]).stdout
)
assert any(r["run_id"] == rid for r in listing["runs"])
def test_resume_json(self, project_dir):
wf = self._write_wf(project_dir, self._WF, "gated3")
rid = json.loads(
self._invoke(project_dir, ["workflow", "run", str(wf), "--json"]).stdout
)["run_id"]
# Non-interactive resume re-runs the gate, which pauses again.
resumed = json.loads(
self._invoke(project_dir, ["workflow", "resume", rid, "--json"]).stdout
)
assert resumed["run_id"] == rid
assert resumed["status"] == "paused"
def test_json_redirect_keeps_stdout_clean(self, capfd):
# While a workflow runs under --json, steps can still write to stdout:
# the gate step prints its prompt and the prompt step runs a
# subprocess that inherits the stdout fd. Both must be redirected to
# stderr so the JSON object on stdout stays parseable. capfd captures
# at the file-descriptor level, so it sees the subprocess output too.
import subprocess
import sys as _sys
from specify_cli import _stdout_to_stderr_when
print("STDOUT_BEFORE")
with _stdout_to_stderr_when(True):
print("PY_LEAK") # Python-level write (gate-style)
subprocess.run( # inherited-fd write (prompt-style)
[_sys.executable, "-c", "print('SUBPROC_LEAK')"],
check=True,
)
print("STDOUT_AFTER")
out, err = capfd.readouterr()
# stdout keeps only what was written outside the guarded block.
assert "STDOUT_BEFORE" in out and "STDOUT_AFTER" in out
assert "PY_LEAK" not in out and "SUBPROC_LEAK" not in out
# The step output is preserved on stderr, not discarded.
assert "PY_LEAK" in err and "SUBPROC_LEAK" in err
def test_json_redirect_inactive_is_noop(self, capfd):
from specify_cli import _stdout_to_stderr_when
with _stdout_to_stderr_when(False):
print("VISIBLE_ON_STDOUT")
out, _ = capfd.readouterr()
assert "VISIBLE_ON_STDOUT" in out
class TestResumeWithInputs:
"""Test that `workflow resume` can accept updated workflow inputs."""