mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c62935316a | ||
|
|
141119efea | ||
|
|
e094cbdb6e | ||
|
|
a9a759450d | ||
|
|
8e5643d4ff | ||
|
|
3a67dad8d2 | ||
|
|
829740e296 | ||
|
|
40d832f90a | ||
|
|
659a41a6cc | ||
|
|
df09fd49c6 |
11
AGENTS.md
11
AGENTS.md
@@ -423,6 +423,17 @@ 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.
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,6 +2,34 @@
|
||||
|
||||
<!-- 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
|
||||
|
||||
- fix: render script command hints with active agent separator (#2649)
|
||||
- chore(tests): fix ruff lint violations in tests/ (#2827)
|
||||
- fix(workflows): validate run_id in RunState.load before touching the … (#2813)
|
||||
- feat(cli): implement specify self upgrade (#2475)
|
||||
- feat(workflows): allow resume to accept updated workflow inputs (#2815)
|
||||
- catalog: rename "superpowers-bridge" to "superspec" (v1.0.1) (#2772)
|
||||
- fix(cli): force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError (#2817)
|
||||
- fix(plan): clarify quickstart validation guide scope (#2805)
|
||||
- chore: release 0.9.2, begin 0.9.3.dev0 development (#2823)
|
||||
|
||||
## [0.9.2] - 2026-06-02
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.
|
||||
|
||||
@@ -20,7 +21,25 @@ Example:
|
||||
specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" -i scope=full
|
||||
```
|
||||
|
||||
> **Note:** All workflow commands require a project already initialized with `specify init`.
|
||||
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>/`.
|
||||
|
||||
## Resume a Workflow
|
||||
|
||||
@@ -31,6 +50,7 @@ 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.
|
||||
|
||||
@@ -46,6 +66,10 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-02T00:00:00Z",
|
||||
"updated_at": "2026-06-04T00: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": "0.7.0",
|
||||
"download_url": "https://github.com/lihan3238/speckit-superpowers-bridge/releases/download/v0.7.0/speckit-superpowers-bridge-v0.7.0.zip",
|
||||
"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",
|
||||
"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-05-28T00:00:00Z"
|
||||
"updated_at": "2026-06-04T00:00:00Z"
|
||||
},
|
||||
"speckit-utils": {
|
||||
"name": "SDD Utilities",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-31T00:00:00Z",
|
||||
"updated_at": "2026-06-03T00: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.1",
|
||||
"version": "1.3.2",
|
||||
"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.1/spec-kit-workflow-preset-v1.3.1.zip",
|
||||
"download_url": "https://github.com/bigsmartben/spec-kit-workflow-preset/releases/download/v1.3.2/spec-kit-workflow-preset-v1.3.2.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-05-28T00:00:00Z"
|
||||
"updated_at": "2026-06-03T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.9.3.dev0"
|
||||
version = "0.9.4"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -26,6 +26,7 @@ Or install globally:
|
||||
specify init --here
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
@@ -86,6 +87,12 @@ 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",
|
||||
@@ -259,65 +266,6 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -401,10 +349,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. 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).
|
||||
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.
|
||||
|
||||
Returns:
|
||||
The skills directory ``Path``, or ``None`` if skills are not active.
|
||||
@@ -425,14 +373,15 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
|
||||
if not isinstance(agent, str) or not agent:
|
||||
return None
|
||||
|
||||
ai_skills_enabled = bool(opts.get("ai_skills"))
|
||||
ai_skills_enabled = _is_ai_skills_enabled(opts)
|
||||
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: use the directory only if it exists.
|
||||
# Kimi native-skills fallback when ai_skills is not boolean True:
|
||||
# use the native skills directory only if it already exists.
|
||||
if not skills_dir.is_dir():
|
||||
return None
|
||||
_ensure_safe_shared_directory(
|
||||
@@ -441,7 +390,7 @@ def resolve_active_skills_dir(project_root: Path) -> Path | None:
|
||||
)
|
||||
return skills_dir
|
||||
|
||||
# ai_skills is explicitly enabled — create the directory safely.
|
||||
# ai_skills is boolean True: create the directory safely.
|
||||
_ensure_safe_shared_directory(
|
||||
project_root, skills_dir, context="agent skills directory",
|
||||
)
|
||||
@@ -1611,6 +1560,7 @@ 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."""
|
||||
@@ -1625,6 +1575,9 @@ 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
|
||||
@@ -1675,11 +1628,15 @@ 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:
|
||||
@@ -1701,7 +1658,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)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}")
|
||||
raise typer.Exit(1)
|
||||
@@ -1714,7 +1671,9 @@ 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)
|
||||
manifest = manager.install_from_directory(
|
||||
bundled_path, speckit_version, priority=priority, force=force
|
||||
)
|
||||
else:
|
||||
# Install from catalog (also resolves display names to IDs)
|
||||
catalog = ExtensionCatalog(project_root)
|
||||
@@ -1735,7 +1694,9 @@ 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)
|
||||
manifest = manager.install_from_directory(
|
||||
bundled_path, speckit_version, priority=priority, force=force
|
||||
)
|
||||
|
||||
if bundled_path is None:
|
||||
# Bundled extensions without a download URL must come from the local package
|
||||
@@ -1771,7 +1732,7 @@ def extension_add(
|
||||
|
||||
try:
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
|
||||
finally:
|
||||
# Clean up downloaded ZIP
|
||||
if zip_path.exists():
|
||||
@@ -2733,22 +2694,95 @@ 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
|
||||
|
||||
project_root = _require_specify_project()
|
||||
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()
|
||||
|
||||
engine = WorkflowEngine(project_root)
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
if not json_output:
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
try:
|
||||
definition = engine.load_workflow(source)
|
||||
definition = engine.load_workflow(source_path if is_file_source else source)
|
||||
except FileNotFoundError:
|
||||
console.print(f"[red]Error:[/red] Workflow not found: {source}")
|
||||
raise typer.Exit(1)
|
||||
@@ -2767,11 +2801,13 @@ def workflow_run(
|
||||
# Parse inputs
|
||||
inputs = _parse_input_values(input_values)
|
||||
|
||||
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {definition.name} ({definition.id})")
|
||||
console.print(f"[dim]Version: {definition.version}[/dim]\n")
|
||||
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")
|
||||
|
||||
try:
|
||||
state = engine.execute(definition, inputs)
|
||||
with _stdout_to_stderr_when(json_output):
|
||||
state = engine.execute(definition, inputs)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(1)
|
||||
@@ -2779,6 +2815,10 @@ 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",
|
||||
@@ -2799,18 +2839,25 @@ 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)
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
if not json_output:
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
inputs = _parse_input_values(input_values)
|
||||
|
||||
try:
|
||||
state = engine.resume(run_id, inputs or None)
|
||||
with _stdout_to_stderr_when(json_output):
|
||||
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)
|
||||
@@ -2821,6 +2868,10 @@ 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",
|
||||
@@ -2834,6 +2885,11 @@ 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
|
||||
@@ -2849,6 +2905,21 @@ 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",
|
||||
@@ -2876,6 +2947,22 @@ 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
|
||||
|
||||
36
src/specify_cli/_init_options.py
Normal file
36
src/specify_cli/_init_options.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""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
|
||||
@@ -15,6 +15,8 @@ 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."""
|
||||
@@ -359,11 +361,6 @@ 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 = {}
|
||||
|
||||
@@ -474,6 +471,29 @@ 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,
|
||||
@@ -806,6 +826,7 @@ 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.
|
||||
|
||||
@@ -817,6 +838,11 @@ 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
|
||||
@@ -824,7 +850,17 @@ 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
|
||||
@@ -832,13 +868,55 @@ 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.exists():
|
||||
continue
|
||||
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
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
|
||||
if agent_dir.exists():
|
||||
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:
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name,
|
||||
@@ -852,8 +930,16 @@ 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
|
||||
|
||||
@@ -892,12 +978,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.exists():
|
||||
if not detect_path.is_dir():
|
||||
continue
|
||||
agent_dir = self._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root,
|
||||
)
|
||||
if agent_dir.exists():
|
||||
if agent_dir.is_dir():
|
||||
try:
|
||||
registered = self.register_commands(
|
||||
agent_name,
|
||||
|
||||
@@ -26,14 +26,15 @@ 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",
|
||||
@@ -830,15 +831,53 @@ class ExtensionManager:
|
||||
be created due to symlink, containment, or permission issues so
|
||||
that callers can fall back gracefully.
|
||||
"""
|
||||
from . import resolve_active_skills_dir, _print_cli_warning
|
||||
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
|
||||
|
||||
try:
|
||||
return resolve_active_skills_dir(self.project_root)
|
||||
skills_dir = 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,
|
||||
@@ -1173,6 +1212,7 @@ class ExtensionManager:
|
||||
register_commands: bool = True,
|
||||
priority: int = 10,
|
||||
link_commands: bool = False,
|
||||
force: bool = False,
|
||||
) -> ExtensionManifest:
|
||||
"""Install extension from a local directory.
|
||||
|
||||
@@ -1183,6 +1223,8 @@ 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
|
||||
@@ -1204,14 +1246,34 @@ class ExtensionManager:
|
||||
|
||||
# Check if already installed
|
||||
if self.registry.is_installed(manifest.id):
|
||||
raise ExtensionError(
|
||||
f"Extension '{manifest.id}' is already installed. "
|
||||
f"Use 'specify extension remove {manifest.id}' first."
|
||||
)
|
||||
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."
|
||||
)
|
||||
|
||||
# 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():
|
||||
@@ -1226,7 +1288,11 @@ 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
|
||||
manifest,
|
||||
dest_dir,
|
||||
self.project_root,
|
||||
link_outputs=link_commands,
|
||||
create_missing_active_skills_dir=True,
|
||||
)
|
||||
|
||||
# Auto-register extension commands as agent skills when --ai-skills
|
||||
@@ -1239,6 +1305,26 @@ 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,
|
||||
@@ -1257,6 +1343,7 @@ class ExtensionManager:
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
) -> ExtensionManifest:
|
||||
"""Install extension from ZIP file.
|
||||
|
||||
@@ -1264,6 +1351,8 @@ 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
|
||||
@@ -1310,7 +1399,9 @@ 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)
|
||||
return self.install_from_directory(
|
||||
extension_dir, speckit_version, priority=priority, force=force
|
||||
)
|
||||
|
||||
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
|
||||
"""Remove an installed extension.
|
||||
@@ -1492,9 +1583,10 @@ 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 bool(init_options.get("ai_skills"))
|
||||
and ai_skills_enabled
|
||||
and bool(agent_config)
|
||||
and agent_config.get("extension") != "/SKILL.md"
|
||||
)
|
||||
@@ -1688,6 +1780,7 @@ 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"
|
||||
@@ -1695,6 +1788,7 @@ 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(
|
||||
@@ -2482,10 +2576,11 @@ class HookExecutor:
|
||||
|
||||
init_options = self._load_init_options()
|
||||
selected_ai = init_options.get("ai")
|
||||
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"))
|
||||
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
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
cursor_skill_mode = selected_ai == "cursor-agent" and bool(init_options.get("ai_skills"))
|
||||
cursor_skill_mode = selected_ai == "cursor-agent" and ai_skills_enabled
|
||||
cline_mode = selected_ai == "cline"
|
||||
|
||||
skill_name = self._skill_name_from_command(command_id)
|
||||
@@ -2742,7 +2837,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
|
||||
|
||||
|
||||
@@ -34,6 +34,21 @@ _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
|
||||
@@ -270,6 +285,16 @@ 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:
|
||||
@@ -345,11 +370,19 @@ class IntegrationBase(ABC):
|
||||
return None
|
||||
|
||||
def list_command_templates(self) -> list[Path]:
|
||||
"""Return sorted list of command template files from the shared directory."""
|
||||
"""Return ordered 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")
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
def command_filename(self, template_name: str) -> str:
|
||||
"""Return the destination filename for a command template.
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
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
|
||||
@@ -15,7 +21,12 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
"name": "Cursor",
|
||||
"folder": ".cursor/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"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.
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
@@ -28,6 +39,50 @@ 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 [
|
||||
|
||||
@@ -29,6 +29,7 @@ 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(
|
||||
@@ -1262,7 +1263,7 @@ class PresetManager:
|
||||
selected_ai = init_opts.get("ai")
|
||||
if not isinstance(selected_ai, str):
|
||||
return []
|
||||
ai_skills_enabled = bool(init_opts.get("ai_skills"))
|
||||
ai_skills_enabled = is_ai_skills_enabled(init_opts)
|
||||
registrar = CommandRegistrar()
|
||||
integration = get_integration(selected_ai)
|
||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
||||
|
||||
@@ -449,10 +449,10 @@ class WorkflowEngine:
|
||||
ValueError:
|
||||
If the workflow YAML is invalid.
|
||||
"""
|
||||
path = Path(source)
|
||||
path = Path(source).expanduser()
|
||||
|
||||
# Try as a direct file path first
|
||||
if path.suffix in (".yml", ".yaml") and path.exists():
|
||||
if path.suffix.lower() in (".yml", ".yaml") and path.is_file():
|
||||
return WorkflowDefinition.from_yaml(path)
|
||||
|
||||
# Try as an installed workflow ID
|
||||
|
||||
@@ -121,6 +121,11 @@ 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"
|
||||
|
||||
@@ -254,8 +254,8 @@ class MarkdownIntegrationTests:
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
|
||||
@@ -100,8 +100,8 @@ class SkillsIntegrationTests:
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
expected_commands = {
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
}
|
||||
|
||||
# Derive command names from the skill directory names
|
||||
@@ -393,8 +393,8 @@ class SkillsIntegrationTests:
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
|
||||
@@ -486,11 +486,11 @@ class TomlIntegrationTests:
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
|
||||
@@ -365,11 +365,11 @@ class YamlIntegrationTests:
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
|
||||
@@ -127,8 +127,8 @@ class TestCopilotIntegration:
|
||||
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
|
||||
assert len(agent_files) == 9
|
||||
expected_commands = {
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "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", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
"analyze", "clarify", "constitution", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _make_copilot(self):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""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
|
||||
@@ -106,3 +107,157 @@ 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"
|
||||
|
||||
|
||||
@@ -213,10 +213,10 @@ class TestGenericIntegration:
|
||||
"command_stem",
|
||||
[
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
|
||||
@@ -17,6 +17,7 @@ import tempfile
|
||||
import shutil
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from specify_cli.extensions import (
|
||||
ExtensionManifest,
|
||||
@@ -26,7 +27,9 @@ from specify_cli.extensions import (
|
||||
|
||||
# ===== Helpers =====
|
||||
|
||||
def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True):
|
||||
def _create_init_options(
|
||||
project_root: Path, ai: str = "claude", ai_skills: Any = True
|
||||
):
|
||||
"""Write a .specify/init-options.json file."""
|
||||
opts_dir = project_root / ".specify"
|
||||
opts_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -35,7 +38,7 @@ def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool
|
||||
"ai": ai,
|
||||
"ai_skills": ai_skills,
|
||||
"script": "sh",
|
||||
}))
|
||||
}), encoding="utf-8")
|
||||
|
||||
|
||||
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
|
||||
@@ -220,11 +223,20 @@ 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("[]")
|
||||
opts_file.write_text("[]", encoding="utf-8")
|
||||
_create_skills_dir(project_dir, ai="claude")
|
||||
manager = ExtensionManager(project_dir)
|
||||
result = manager._get_skills_dir()
|
||||
@@ -655,6 +667,393 @@ 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 =====
|
||||
|
||||
@@ -738,7 +1137,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("[]")
|
||||
opts_file.write_text("[]", encoding="utf-8")
|
||||
_create_skills_dir(project_dir, ai="claude")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
|
||||
@@ -782,6 +782,71 @@ 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)
|
||||
@@ -793,6 +858,102 @@ 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
|
||||
@@ -4788,6 +4949,26 @@ 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"
|
||||
@@ -5114,3 +5295,69 @@ $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)
|
||||
|
||||
@@ -2255,6 +2255,51 @@ 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
|
||||
|
||||
@@ -2348,6 +2393,51 @@ 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.
|
||||
|
||||
238
tests/test_workflow_run_without_project.py
Normal file
238
tests/test_workflow_run_without_project.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""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
|
||||
@@ -601,15 +601,18 @@ 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 -p and the skill invocation
|
||||
# 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.
|
||||
call_args = mock_run.call_args
|
||||
assert call_args[0][0][0] == "claude"
|
||||
assert call_args[0][0][0] == "/usr/local/bin/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]
|
||||
@@ -638,6 +641,7 @@ 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)
|
||||
|
||||
@@ -3134,6 +3138,158 @@ 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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user