mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
9 Commits
v0.9.5
...
benbtg/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ddc10ef2d | ||
|
|
90832d19bf | ||
|
|
d8a81b23b5 | ||
|
|
a0305fc511 | ||
|
|
d977feea01 | ||
|
|
c53a08802c | ||
|
|
4ec4635dd1 | ||
|
|
7106858c4e | ||
|
|
072b32cba0 |
12
README.md
12
README.md
@@ -79,7 +79,7 @@ Bare `specify self upgrade` executes immediately, matching the no-prompt behavio
|
||||
|
||||
### 3. Establish project principles
|
||||
|
||||
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
|
||||
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
|
||||
|
||||
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
|
||||
|
||||
@@ -584,6 +584,16 @@ Once the implementation is complete, test the application and resolve any runtim
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=github%2Fspec-kit&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=github/spec-kit&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=github/spec-kit&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=github/spec-kit&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 💬 Support
|
||||
|
||||
For support, please open a [GitHub issue](https://github.com/github/spec-kit/issues/new). We welcome bug reports, feature requests, and questions about using Spec-Driven Development.
|
||||
|
||||
@@ -15,7 +15,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 25 templates, 33 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Game Narrative Writing | Spec-Driven Development for interactive game narrative pre-production for video games. Authors write in a portable generic format, Twine/Sugarcube (.twee) or Ink (.ink). Covers choice-IF, visual novels, and branching dialogue. Supports Tier 1 mechanic hooks (flag, counter, inventory, timer, trust, currency, npc_state, ending_condition), multi-ending design, series carry-over variable registry, and NPC-focused character architecture. | 22 templates, 36 commands, 2 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
|
||||
@@ -52,13 +52,19 @@ provides:
|
||||
description: string
|
||||
required: boolean # Default: false
|
||||
|
||||
hooks: # Optional, event hooks
|
||||
hooks: # Optional, event hooks. Each event accepts either form below.
|
||||
event_name: # e.g., "after_specify", "after_plan", "after_tasks", "after_implement"
|
||||
command: string # Command to execute
|
||||
priority: integer # Optional, >= 1, default 10 (lower runs first)
|
||||
optional: boolean # Default: true
|
||||
prompt: string # Prompt text for optional hooks
|
||||
description: string # Hook description
|
||||
condition: string # Optional, condition expression
|
||||
another_event: # Any event may instead use a list of mappings (multiple commands)
|
||||
- command: string # Same fields as the single mapping, per entry
|
||||
priority: integer
|
||||
- command: string
|
||||
priority: integer
|
||||
|
||||
tags: # Optional, array of tags (2-10 recommended)
|
||||
- string
|
||||
@@ -109,8 +115,10 @@ defaults: # Optional, default configuration values
|
||||
|
||||
- **Type**: object
|
||||
- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_analyze`)
|
||||
- **Value**: A single hook mapping, or a list of hook mappings to register multiple commands on one event
|
||||
- **Description**: Hooks that execute at lifecycle events
|
||||
- **Events**: Defined by core spec-kit commands
|
||||
- **Ordering**: Within an event, hooks run by ascending `priority` (integer ≥ 1, default 10; lower runs first; equal priorities keep authoring order via a stable sort)
|
||||
|
||||
---
|
||||
|
||||
@@ -535,7 +543,9 @@ Examples:
|
||||
|
||||
### Hook Definition
|
||||
|
||||
**In extension.yml**:
|
||||
Each event accepts either a single hook mapping or a list of mappings. A list registers multiple commands on the same event.
|
||||
|
||||
**Single mapping (in extension.yml)**:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
@@ -547,6 +557,24 @@ hooks:
|
||||
condition: null
|
||||
```
|
||||
|
||||
**List of mappings with priority**:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
after_plan:
|
||||
- command: "speckit.my-ext.verify"
|
||||
priority: 5
|
||||
optional: false
|
||||
description: "Verify the plan"
|
||||
- command: "speckit.my-ext.report"
|
||||
priority: 10
|
||||
optional: true
|
||||
prompt: "Generate the report?"
|
||||
description: "Generate a report from the plan"
|
||||
```
|
||||
|
||||
Within a single manifest list, a repeated `command` is deduped as "last wins" and moved to the end, so it also breaks equal-priority ties in authoring order.
|
||||
|
||||
### Hook Events
|
||||
|
||||
Standard events (defined by core):
|
||||
|
||||
@@ -206,9 +206,12 @@ Available hook points:
|
||||
- `before_constitution` / `after_constitution`: Before/after constitution update
|
||||
- `before_taskstoissues` / `after_taskstoissues`: Before/after tasks-to-issues conversion
|
||||
|
||||
Each event accepts a single hook object or a list of hook objects (multiple commands on one event).
|
||||
|
||||
Hook object:
|
||||
|
||||
- `command`: Command to execute (typically from `provides.commands`, but can reference any registered command)
|
||||
- `priority`: Run order within the event (integer ≥ 1, default 10; lower runs first; equal priorities keep authoring order)
|
||||
- `optional`: If true, prompt user before executing
|
||||
- `prompt`: Prompt text for optional hooks
|
||||
- `description`: Hook description
|
||||
@@ -655,6 +658,23 @@ hooks:
|
||||
description: "Analyze tasks after generation"
|
||||
```
|
||||
|
||||
Multiple commands on one event, ordered by `priority` (lower runs first):
|
||||
|
||||
```yaml
|
||||
# extension.yml
|
||||
hooks:
|
||||
after_plan:
|
||||
- command: "speckit.my-ext.verify"
|
||||
priority: 5
|
||||
optional: false
|
||||
description: "Verify the plan"
|
||||
- command: "speckit.my-ext.report"
|
||||
priority: 10
|
||||
optional: true
|
||||
prompt: "Generate the report?"
|
||||
description: "Generate a report from the plan"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-04T00:00:00Z",
|
||||
"updated_at": "2026-06-08T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -242,11 +242,11 @@
|
||||
"id": "architecture-guard",
|
||||
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.8.9",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.9.zip",
|
||||
"version": "1.8.17",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.17.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/README.md",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md",
|
||||
"changelog": "https://github.com/DyanGalih/spec-kit-architecture-guard/releases",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
@@ -269,7 +269,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-05T07:26:00Z",
|
||||
"updated_at": "2026-05-27T00:00:00Z"
|
||||
"updated_at": "2026-06-08T00:00:00Z"
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
@@ -2554,9 +2554,9 @@
|
||||
"name": "Security Review",
|
||||
"id": "security-review",
|
||||
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.5.0",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.0.zip",
|
||||
"author": "Spec-Kit Security Team",
|
||||
"version": "1.5.3",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.5.3.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
|
||||
@@ -2580,7 +2580,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-03T03:24:03Z",
|
||||
"updated_at": "2026-05-11T14:58:00Z"
|
||||
"updated_at": "2026-06-08T00:00:00Z"
|
||||
},
|
||||
"sf": {
|
||||
"name": "SFSpeckit — Salesforce Spec-Driven Development",
|
||||
|
||||
@@ -79,6 +79,14 @@ hooks:
|
||||
# optional: false # Auto-execute without prompting
|
||||
# description: "Runs automatically after implementation"
|
||||
|
||||
# MULTIPLE COMMANDS ON ONE EVENT: use a list of entries.
|
||||
# Add optional `priority` (integer >= 1, default 10) to order them, lowest first.
|
||||
# after_plan:
|
||||
# - command: "speckit.my-extension.verify"
|
||||
# priority: 5
|
||||
# - command: "speckit.my-extension.report"
|
||||
# priority: 10
|
||||
|
||||
# CUSTOMIZE: Add relevant tags (2-5 recommended)
|
||||
# Used for discovery in catalog
|
||||
tags:
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
"id": "generic",
|
||||
"name": "Generic (bring your own agent)",
|
||||
"version": "1.0.0",
|
||||
"description": "Generic integration for any agent via --ai-commands-dir",
|
||||
"description": "Generic integration for any agent via --integration-options=\"--commands-dir <dir>\"",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["generic"]
|
||||
|
||||
@@ -224,11 +224,11 @@
|
||||
"fiction-book-writing": {
|
||||
"name": "Fiction Book Writing",
|
||||
"id": "fiction-book-writing",
|
||||
"version": "1.8.1",
|
||||
"description": "Spec-Driven Development for novel and long-form fiction. 33 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
|
||||
"version": "1.9.0",
|
||||
"description": "Spec-Driven Development for novel and long-form fiction. 34 AI commands from idea to submission: story bible governance, 9 POV modes, all major plot structure frameworks, scene-by-scene drafting with quality gates, audiobook pipeline (SSML/ElevenLabs), cover design, illustrations, sensitivity review, pacing and prose statistics, and pandoc-based export to DOCX/EPUB/LaTeX. Two style modes: author voice sample extraction or humanized-AI prose with 5 craft profiles. 12 languages supported. Support for offline semantic search.",
|
||||
"author": "Andreas Daumann",
|
||||
"repository": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.8.1.zip",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-fiction-book-writing/archive/refs/tags/v1.9.0.zip",
|
||||
"homepage": "https://github.com/adaumann/speckit-preset-fiction-book-writing",
|
||||
"documentation": "https://github.com/adaumann/speckit-preset-fiction-book-writing/blob/main/fiction-book-writing/README.md",
|
||||
"license": "MIT",
|
||||
@@ -236,8 +236,8 @@
|
||||
"speckit_version": ">=0.5.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 25,
|
||||
"commands": 33,
|
||||
"templates": 26,
|
||||
"commands": 34,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
@@ -256,7 +256,7 @@
|
||||
"language-support"
|
||||
],
|
||||
"created_at": "2026-04-09T08:00:00Z",
|
||||
"updated_at": "2026-05-24T08:00:00Z"
|
||||
"updated_at": "2026-06-02T08:00:00Z"
|
||||
},
|
||||
"game-narrative-writing": {
|
||||
"name": "Game Narrative Writing",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.9.5"
|
||||
version = "0.9.6.dev0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -82,8 +82,6 @@ from ._version import (
|
||||
)
|
||||
from ._agent_config import (
|
||||
AGENT_CONFIG as AGENT_CONFIG,
|
||||
AI_ASSISTANT_ALIASES as AI_ASSISTANT_ALIASES,
|
||||
AI_ASSISTANT_HELP as AI_ASSISTANT_HELP,
|
||||
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
|
||||
@@ -17,29 +17,4 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
|
||||
|
||||
DEFAULT_INIT_INTEGRATION = "copilot"
|
||||
|
||||
AI_ASSISTANT_ALIASES: dict[str, str] = {
|
||||
"kiro": "kiro-cli",
|
||||
}
|
||||
|
||||
|
||||
def _build_ai_assistant_help() -> str:
|
||||
non_generic_agents = sorted(agent for agent in AGENT_CONFIG if agent != "generic")
|
||||
base_help = (
|
||||
f"AI assistant to use: {', '.join(non_generic_agents)}, "
|
||||
"or generic (requires --ai-commands-dir)."
|
||||
)
|
||||
if not AI_ASSISTANT_ALIASES:
|
||||
return base_help
|
||||
alias_phrases = []
|
||||
for alias, target in sorted(AI_ASSISTANT_ALIASES.items()):
|
||||
alias_phrases.append(f"'{alias}' as an alias for '{target}'")
|
||||
if len(alias_phrases) == 1:
|
||||
aliases_text = alias_phrases[0]
|
||||
else:
|
||||
aliases_text = ", ".join(alias_phrases[:-1]) + " and " + alias_phrases[-1]
|
||||
return base_help + " Use " + aliases_text + "."
|
||||
|
||||
|
||||
AI_ASSISTANT_HELP: str = _build_ai_assistant_help()
|
||||
|
||||
SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -14,8 +13,6 @@ from rich.panel import Panel
|
||||
|
||||
from .._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
AI_ASSISTANT_ALIASES,
|
||||
AI_ASSISTANT_HELP,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
@@ -28,31 +25,6 @@ from .._assets import (
|
||||
from .._console import StepTracker, console, select_with_arrows, show_banner
|
||||
from .._utils import check_tool, init_git_repo, is_git_repo
|
||||
|
||||
def _build_integration_equivalent(
|
||||
integration_key: str,
|
||||
ai_commands_dir: str | None = None,
|
||||
) -> str:
|
||||
parts = [f"--integration {integration_key}"]
|
||||
if integration_key == "generic" and ai_commands_dir:
|
||||
parts.append(
|
||||
f'--integration-options="--commands-dir {shlex.quote(ai_commands_dir)}"'
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _build_ai_deprecation_warning(
|
||||
integration_key: str,
|
||||
ai_commands_dir: str | None = None,
|
||||
) -> str:
|
||||
replacement = _build_integration_equivalent(
|
||||
integration_key,
|
||||
ai_commands_dir=ai_commands_dir,
|
||||
)
|
||||
return (
|
||||
"[bold]--ai[/bold] is deprecated and will no longer be available in version 0.10.0 or later.\n\n"
|
||||
f"Use [bold]{replacement}[/bold] instead."
|
||||
)
|
||||
|
||||
|
||||
def _stdin_is_interactive() -> bool:
|
||||
return sys.stdin.isatty()
|
||||
@@ -97,8 +69,6 @@ def register(app: typer.Typer) -> None:
|
||||
@app.command()
|
||||
def init(
|
||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
||||
ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP),
|
||||
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"),
|
||||
script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"),
|
||||
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"),
|
||||
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
||||
@@ -107,11 +77,10 @@ def register(app: typer.Typer) -> None:
|
||||
skip_tls: bool = typer.Option(False, "--skip-tls", help="Deprecated (no-op). Previously: skip SSL/TLS verification.", hidden=True),
|
||||
debug: bool = typer.Option(False, "--debug", help="Deprecated. Previously: show verbose diagnostic output; currently only prints additional diagnostic details on failure.", hidden=True),
|
||||
github_token: str = typer.Option(None, "--github-token", help="Deprecated (no-op). Previously: GitHub token for API requests.", hidden=True),
|
||||
ai_skills: bool = typer.Option(False, "--ai-skills", help="Install Prompt.MD templates as agent skills (requires --ai)"),
|
||||
offline: bool = typer.Option(False, "--offline", help="Deprecated (no-op). All scaffolding now uses bundled assets.", hidden=True),
|
||||
preset: str = typer.Option(None, "--preset", help="Install a preset during initialization (by preset ID)"),
|
||||
branch_numbering: str = typer.Option(None, "--branch-numbering", help="Branch numbering strategy: 'sequential' (001, 002, …, 1000, … — expands past 999 automatically) or 'timestamp' (YYYYMMDD-HHMMSS)"),
|
||||
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
|
||||
integration: str = typer.Option(None, "--integration", help="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."),
|
||||
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
):
|
||||
"""
|
||||
@@ -163,27 +132,6 @@ def register(app: typer.Typer) -> None:
|
||||
from ..integration_runtime import with_integration_setting as _with_integration_setting
|
||||
|
||||
show_banner()
|
||||
ai_deprecation_warning: str | None = None
|
||||
|
||||
if ai_assistant and ai_assistant.startswith("--"):
|
||||
console.print(f"[red]Error:[/red] Invalid value for --ai: '{ai_assistant}'")
|
||||
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai?")
|
||||
console.print("[yellow]Example:[/yellow] specify init --integration claude --here")
|
||||
console.print(f"[yellow]Available agents:[/yellow] {', '.join(AGENT_CONFIG.keys())}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_commands_dir and ai_commands_dir.startswith("--"):
|
||||
console.print(f"[red]Error:[/red] Invalid value for --ai-commands-dir: '{ai_commands_dir}'")
|
||||
console.print("[yellow]Hint:[/yellow] Did you forget to provide a value for --ai-commands-dir?")
|
||||
console.print("[yellow]Example:[/yellow] specify init --integration generic --integration-options=\"--commands-dir .myagent/commands/\"")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_assistant:
|
||||
ai_assistant = AI_ASSISTANT_ALIASES.get(ai_assistant, ai_assistant)
|
||||
|
||||
if integration and ai_assistant:
|
||||
console.print("[red]Error:[/red] --integration and --ai are mutually exclusive")
|
||||
raise typer.Exit(1)
|
||||
|
||||
from ..integrations import INTEGRATION_REGISTRY, get_integration
|
||||
if integration:
|
||||
@@ -193,35 +141,6 @@ def register(app: typer.Typer) -> None:
|
||||
available = ", ".join(sorted(INTEGRATION_REGISTRY))
|
||||
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
||||
raise typer.Exit(1)
|
||||
ai_assistant = integration
|
||||
elif ai_assistant:
|
||||
resolved_integration = get_integration(ai_assistant)
|
||||
if not resolved_integration:
|
||||
console.print(f"[red]Error:[/red] Unknown agent '{ai_assistant}'. Choose from: {', '.join(sorted(INTEGRATION_REGISTRY))}")
|
||||
raise typer.Exit(1)
|
||||
ai_deprecation_warning = _build_ai_deprecation_warning(
|
||||
resolved_integration.key,
|
||||
ai_commands_dir=ai_commands_dir,
|
||||
)
|
||||
|
||||
if ai_assistant or integration:
|
||||
if ai_skills:
|
||||
from ..integrations.base import SkillsIntegration as _SkillsCheck
|
||||
if isinstance(resolved_integration, _SkillsCheck):
|
||||
console.print(
|
||||
"[dim]Note: --ai-skills is not needed; "
|
||||
"skills are the default for this integration.[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
"[dim]Note: --ai-skills has no effect with "
|
||||
f"{resolved_integration.key}; this integration uses commands, not skills.[/dim]"
|
||||
)
|
||||
if ai_commands_dir and resolved_integration.key != "generic":
|
||||
console.print(
|
||||
"[dim]Note: --ai-commands-dir is deprecated; "
|
||||
'use [bold]--integration generic --integration-options="--commands-dir <dir>"[/bold] instead.[/dim]'
|
||||
)
|
||||
|
||||
if no_git:
|
||||
console.print(
|
||||
@@ -242,11 +161,6 @@ def register(app: typer.Typer) -> None:
|
||||
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_skills and not ai_assistant:
|
||||
console.print("[red]Error:[/red] --ai-skills requires --ai to be specified")
|
||||
console.print("[yellow]Usage:[/yellow] specify init <project> --ai <agent> --ai-skills")
|
||||
raise typer.Exit(1)
|
||||
|
||||
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
|
||||
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
|
||||
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
|
||||
@@ -295,11 +209,11 @@ def register(app: typer.Typer) -> None:
|
||||
console.print(error_panel)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if ai_assistant:
|
||||
if ai_assistant not in AGENT_CONFIG:
|
||||
console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
||||
if integration:
|
||||
if integration not in AGENT_CONFIG:
|
||||
console.print(f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
||||
raise typer.Exit(1)
|
||||
selected_ai = ai_assistant
|
||||
selected_ai = integration
|
||||
elif not _stdin_is_interactive():
|
||||
console.print(
|
||||
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
|
||||
@@ -314,17 +228,16 @@ def register(app: typer.Typer) -> None:
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
)
|
||||
|
||||
if not ai_assistant:
|
||||
if not integration:
|
||||
resolved_integration = get_integration(selected_ai)
|
||||
if not resolved_integration:
|
||||
console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if selected_ai == "generic" and not integration_options:
|
||||
if not ai_commands_dir:
|
||||
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic")
|
||||
console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]')
|
||||
raise typer.Exit(1)
|
||||
console.print("[red]Error:[/red] --integration generic requires --integration-options with --commands-dir")
|
||||
console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]')
|
||||
raise typer.Exit(1)
|
||||
|
||||
current_dir = Path.cwd()
|
||||
|
||||
@@ -414,10 +327,6 @@ def register(app: typer.Typer) -> None:
|
||||
)
|
||||
|
||||
integration_parsed_options: dict[str, Any] = {}
|
||||
if ai_commands_dir:
|
||||
integration_parsed_options["commands_dir"] = ai_commands_dir
|
||||
if ai_skills:
|
||||
integration_parsed_options["skills"] = True
|
||||
if integration_options:
|
||||
extra = _parse_integration_options(resolved_integration, integration_options)
|
||||
if extra:
|
||||
@@ -675,7 +584,7 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||
if agent_config:
|
||||
agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"]
|
||||
agent_folder = agent_config["folder"] or integration_parsed_options.get("commands_dir")
|
||||
if agent_folder:
|
||||
security_notice = Panel(
|
||||
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
||||
@@ -687,16 +596,6 @@ def register(app: typer.Typer) -> None:
|
||||
console.print()
|
||||
console.print(security_notice)
|
||||
|
||||
if ai_deprecation_warning:
|
||||
deprecation_notice = Panel(
|
||||
ai_deprecation_warning,
|
||||
title="[bold red]Deprecation Warning[/bold red]",
|
||||
border_style="red",
|
||||
padding=(1, 2),
|
||||
)
|
||||
console.print()
|
||||
console.print(deprecation_notice)
|
||||
|
||||
if git_default_notice:
|
||||
default_change_notice = Panel(
|
||||
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
|
||||
@@ -720,24 +619,24 @@ def register(app: typer.Typer) -> None:
|
||||
from ..integrations.base import SkillsIntegration as _SkillsInt
|
||||
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
|
||||
|
||||
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
|
||||
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
|
||||
codex_skill_mode = selected_ai == "codex" and _is_skills_integration
|
||||
claude_skill_mode = selected_ai == "claude" and _is_skills_integration
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
||||
trae_skill_mode = selected_ai == "trae"
|
||||
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
|
||||
cursor_agent_skill_mode = selected_ai == "cursor-agent" and _is_skills_integration
|
||||
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
|
||||
devin_skill_mode = selected_ai == "devin"
|
||||
cline_skill_mode = selected_ai == "cline"
|
||||
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
|
||||
|
||||
if codex_skill_mode and not ai_skills:
|
||||
if codex_skill_mode:
|
||||
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
||||
step_num += 1
|
||||
if claude_skill_mode and not ai_skills:
|
||||
if claude_skill_mode:
|
||||
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
|
||||
step_num += 1
|
||||
if cursor_agent_skill_mode and not ai_skills:
|
||||
if cursor_agent_skill_mode:
|
||||
steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
|
||||
step_num += 1
|
||||
if devin_skill_mode:
|
||||
|
||||
@@ -41,6 +41,8 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
||||
})
|
||||
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
|
||||
|
||||
DEFAULT_HOOK_PRIORITY = 10
|
||||
|
||||
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
|
||||
|
||||
|
||||
@@ -89,19 +91,21 @@ class CompatibilityError(ExtensionError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_priority(value: Any, default: int = 10) -> int:
|
||||
def normalize_priority(value: Any, default: int = DEFAULT_HOOK_PRIORITY) -> int:
|
||||
"""Normalize a stored priority value for sorting and display.
|
||||
|
||||
Corrupted registry data may contain missing, non-numeric, or non-positive
|
||||
values. In those cases, fall back to the default priority.
|
||||
Corrupted registry data may contain missing, non-numeric, non-positive, or
|
||||
boolean values. In those cases, fall back to the default priority.
|
||||
|
||||
Args:
|
||||
value: Priority value to normalize (may be int, str, None, etc.)
|
||||
default: Default priority to use for invalid values (default: 10)
|
||||
default: Default priority to use for invalid values
|
||||
|
||||
Returns:
|
||||
Normalized priority as positive integer (>= 1)
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
try:
|
||||
priority = int(value)
|
||||
except (TypeError, ValueError):
|
||||
@@ -109,6 +113,15 @@ def normalize_priority(value: Any, default: int = 10) -> int:
|
||||
return priority if priority >= 1 else default
|
||||
|
||||
|
||||
def coerce_hook_entries(hook_config: Any) -> List[Any]:
|
||||
"""Return a hook event's config as a list of entries.
|
||||
|
||||
A hook event may be declared as a single mapping or a list of mappings.
|
||||
Both shapes are normalized to a list so callers can iterate uniformly.
|
||||
"""
|
||||
return hook_config if isinstance(hook_config, list) else [hook_config]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogEntry(BaseCatalogEntry):
|
||||
"""Represents a single catalog entry in the catalog stack."""
|
||||
@@ -215,17 +228,36 @@ class ExtensionManifest:
|
||||
"Extension must provide at least one command or hook"
|
||||
)
|
||||
|
||||
# Validate hook values (if present)
|
||||
# Validate hook values (if present).
|
||||
# Each event is a single mapping or a list of mappings.
|
||||
if hooks:
|
||||
for hook_name, hook_config in hooks.items():
|
||||
if not isinstance(hook_config, dict):
|
||||
if isinstance(hook_config, list) and not hook_config:
|
||||
raise ValidationError(
|
||||
f"Invalid hook '{hook_name}': expected a mapping"
|
||||
)
|
||||
if not hook_config.get("command"):
|
||||
raise ValidationError(
|
||||
f"Hook '{hook_name}' missing required 'command' field"
|
||||
f"Invalid hook '{hook_name}': list must contain at least one entry"
|
||||
)
|
||||
for entry in coerce_hook_entries(hook_config):
|
||||
if not isinstance(entry, dict):
|
||||
raise ValidationError(
|
||||
f"Invalid hook '{hook_name}': "
|
||||
"expected a mapping or list of mappings"
|
||||
)
|
||||
if not entry.get("command"):
|
||||
raise ValidationError(
|
||||
f"Hook '{hook_name}' missing required 'command' field"
|
||||
)
|
||||
if "priority" in entry:
|
||||
priority = entry["priority"]
|
||||
if not isinstance(priority, int) or isinstance(priority, bool):
|
||||
raise ValidationError(
|
||||
f"Hook '{hook_name}' has invalid 'priority': "
|
||||
"must be an integer"
|
||||
)
|
||||
if priority < 1:
|
||||
raise ValidationError(
|
||||
f"Hook '{hook_name}' has invalid 'priority': "
|
||||
"must be >= 1"
|
||||
)
|
||||
|
||||
# Validate commands; track renames so hook references can be rewritten.
|
||||
rename_map: Dict[str, str] = {}
|
||||
@@ -275,28 +307,30 @@ class ExtensionManifest:
|
||||
# an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when
|
||||
# the reference is changed so extension authors know to update the manifest.
|
||||
for hook_name, hook_data in self.data.get("hooks", {}).items():
|
||||
if not isinstance(hook_data, dict):
|
||||
raise ValidationError(
|
||||
f"Hook '{hook_name}' must be a mapping, got {type(hook_data).__name__}"
|
||||
)
|
||||
command_ref = hook_data.get("command")
|
||||
if not isinstance(command_ref, str):
|
||||
continue
|
||||
# Step 1: apply any rename from the auto-correction pass.
|
||||
after_rename = rename_map.get(command_ref, command_ref)
|
||||
# Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'.
|
||||
parts = after_rename.split(".")
|
||||
if len(parts) == 2 and parts[0] == ext["id"]:
|
||||
final_ref = f"speckit.{ext['id']}.{parts[1]}"
|
||||
else:
|
||||
final_ref = after_rename
|
||||
if final_ref != command_ref:
|
||||
hook_data["command"] = final_ref
|
||||
self.warnings.append(
|
||||
f"Hook '{hook_name}' referenced command '{command_ref}'; "
|
||||
f"updated to canonical form '{final_ref}'. "
|
||||
f"The extension author should update the manifest."
|
||||
)
|
||||
for entry in coerce_hook_entries(hook_data):
|
||||
if not isinstance(entry, dict):
|
||||
raise ValidationError(
|
||||
f"Hook '{hook_name}' must be a mapping or list of mappings, "
|
||||
f"got {type(entry).__name__}"
|
||||
)
|
||||
command_ref = entry.get("command")
|
||||
if not isinstance(command_ref, str):
|
||||
continue
|
||||
# Step 1: apply any rename from the auto-correction pass.
|
||||
after_rename = rename_map.get(command_ref, command_ref)
|
||||
# Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'.
|
||||
parts = after_rename.split(".")
|
||||
if len(parts) == 2 and parts[0] == ext["id"]:
|
||||
final_ref = f"speckit.{ext['id']}.{parts[1]}"
|
||||
else:
|
||||
final_ref = after_rename
|
||||
if final_ref != command_ref:
|
||||
entry["command"] = final_ref
|
||||
self.warnings.append(
|
||||
f"Hook '{hook_name}' referenced command '{command_ref}'; "
|
||||
f"updated to canonical form '{final_ref}'. "
|
||||
f"The extension author should update the manifest."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
|
||||
@@ -889,7 +923,7 @@ class ExtensionManager:
|
||||
|
||||
For every command in the extension manifest, creates a SKILL.md
|
||||
file in the agent's skills directory following the agentskills.io
|
||||
specification. This is only done when ``--ai-skills`` was used
|
||||
specification. This is only done when skills mode was used
|
||||
during project initialisation.
|
||||
|
||||
Args:
|
||||
@@ -1295,7 +1329,7 @@ class ExtensionManager:
|
||||
create_missing_active_skills_dir=True,
|
||||
)
|
||||
|
||||
# Auto-register extension commands as agent skills when --ai-skills
|
||||
# Auto-register extension commands as agent skills when skills mode
|
||||
# was used during project initialisation (feature parity).
|
||||
registered_skills = self._register_extension_skills(
|
||||
manifest, dest_dir, link_outputs=link_commands
|
||||
@@ -2734,9 +2768,6 @@ class HookExecutor:
|
||||
# Always ensure the extension is in the installed list
|
||||
self.register_extension(manifest.id)
|
||||
|
||||
if not hasattr(manifest, "hooks") or not manifest.hooks:
|
||||
return
|
||||
|
||||
config = self.get_project_config()
|
||||
|
||||
# Ensure config is a dict (defensive)
|
||||
@@ -2762,39 +2793,68 @@ class HookExecutor:
|
||||
config["hooks"][h_name] = sanitized_h_list
|
||||
changed = True
|
||||
|
||||
# Purge this extension's entries from events the new manifest no longer
|
||||
# declares, so dropping an event on reinstall leaves no orphans.
|
||||
declared_events = set(manifest.hooks.keys())
|
||||
for h_name in list(config["hooks"].keys()):
|
||||
if h_name in declared_events:
|
||||
continue
|
||||
kept = [
|
||||
h for h in config["hooks"][h_name]
|
||||
if not (isinstance(h, dict) and h.get("extension") == manifest.id)
|
||||
]
|
||||
if kept != config["hooks"][h_name]:
|
||||
config["hooks"][h_name] = kept
|
||||
changed = True
|
||||
|
||||
# Register each hook
|
||||
for hook_name, hook_config in manifest.hooks.items():
|
||||
if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list):
|
||||
config["hooks"][hook_name] = []
|
||||
changed = True
|
||||
|
||||
# Add hook entry
|
||||
hook_entry = {
|
||||
"extension": manifest.id,
|
||||
"command": hook_config.get("command"),
|
||||
"enabled": True,
|
||||
"optional": hook_config.get("optional", True),
|
||||
"prompt": hook_config.get(
|
||||
"prompt", f"Execute {hook_config.get('command')}?"
|
||||
),
|
||||
"description": hook_config.get("description", ""),
|
||||
"condition": hook_config.get("condition"),
|
||||
}
|
||||
# Key by command to dedup within the manifest. Deleting before
|
||||
# re-insert moves a duplicate to the end so "last wins" also breaks ties.
|
||||
new_entries: Dict[str, Dict[str, Any]] = {}
|
||||
for entry in coerce_hook_entries(hook_config):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
command = entry.get("command")
|
||||
if not command:
|
||||
continue
|
||||
if command in new_entries:
|
||||
del new_entries[command]
|
||||
new_entries[command] = {
|
||||
"extension": manifest.id,
|
||||
"command": command,
|
||||
"enabled": True,
|
||||
"optional": entry.get("optional", True),
|
||||
"priority": normalize_priority(
|
||||
entry.get("priority"), DEFAULT_HOOK_PRIORITY
|
||||
),
|
||||
"prompt": entry.get("prompt", f"Execute {command}?"),
|
||||
"description": entry.get("description", ""),
|
||||
"condition": entry.get("condition"),
|
||||
}
|
||||
|
||||
# Deduplicate: remove all existing entries for this extension on this
|
||||
# hook event, then append the single canonical entry. This prevents
|
||||
# multiple hooks firing when hand-edited or older versions leave
|
||||
# duplicate entries behind. (Feedback from review)
|
||||
# Purge then re-add all of this extension's entries for the event.
|
||||
# A reinstall with a changed shape (single<->list or a shorter list)
|
||||
# then leaves no orphaned entries behind.
|
||||
original_list = config["hooks"][hook_name]
|
||||
deduped = [
|
||||
h for h in original_list
|
||||
if not (isinstance(h, dict) and h.get("extension") == manifest.id)
|
||||
]
|
||||
deduped.append(hook_entry)
|
||||
deduped.extend(new_entries.values())
|
||||
if deduped != original_list:
|
||||
config["hooks"][hook_name] = deduped
|
||||
changed = True
|
||||
|
||||
non_empty = {name: hooks for name, hooks in config["hooks"].items() if hooks}
|
||||
if non_empty != config["hooks"]:
|
||||
config["hooks"] = non_empty
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self.save_project_config(config)
|
||||
|
||||
@@ -2838,19 +2898,26 @@ class HookExecutor:
|
||||
self.save_project_config(config)
|
||||
|
||||
def get_hooks_for_event(self, event_name: str) -> List[Dict[str, Any]]:
|
||||
"""Get all registered hooks for a specific event.
|
||||
"""Get all enabled hooks for a specific event, sorted by priority ascending.
|
||||
|
||||
Lower ``priority`` runs first. Ties keep insertion order via a stable
|
||||
sort. Missing or corrupted on-disk priorities fall back to the default.
|
||||
|
||||
Args:
|
||||
event_name: Name of the event (e.g., 'after_tasks')
|
||||
|
||||
Returns:
|
||||
List of hook configurations
|
||||
List of enabled hook configurations sorted by priority.
|
||||
"""
|
||||
config = self.get_project_config()
|
||||
hooks = config.get("hooks", {}).get(event_name, [])
|
||||
|
||||
# Filter to enabled hooks only
|
||||
return [h for h in hooks if h.get("enabled", True)]
|
||||
enabled = [h for h in hooks if h.get("enabled", True)]
|
||||
return sorted(
|
||||
enabled,
|
||||
key=lambda h: normalize_priority(h.get("priority"), DEFAULT_HOOK_PRIORITY),
|
||||
)
|
||||
|
||||
def should_execute_hook(self, hook: Dict[str, Any]) -> bool:
|
||||
"""Determine if a hook should be executed based on its condition.
|
||||
|
||||
@@ -22,7 +22,7 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
"folder": ".cursor/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://docs.cursor.com/en/cli/overview",
|
||||
# IDE-first integration: ``specify init --ai cursor-agent`` must
|
||||
# IDE-first integration: ``specify init --integration 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
|
||||
|
||||
@@ -7,7 +7,7 @@ AI agent framework by Nous Research. It stores skills in
|
||||
Usage::
|
||||
|
||||
specify init my-project --integration hermes
|
||||
specify init --here --ai hermes
|
||||
specify init --here --integration hermes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1219,7 +1219,7 @@ class PresetManager:
|
||||
directory. If so, the skill is overwritten with content derived
|
||||
from the preset's command file. This ensures that presets that
|
||||
override commands also propagate to the agentskills.io skill
|
||||
layer when ``--ai-skills`` was used during project initialisation.
|
||||
layer when skills mode was used during project initialisation.
|
||||
|
||||
Args:
|
||||
manifest: Preset manifest.
|
||||
@@ -1559,7 +1559,7 @@ class PresetManager:
|
||||
"registered_commands": registered_commands,
|
||||
})
|
||||
|
||||
# Update corresponding skills when --ai-skills was previously used
|
||||
# Update corresponding skills when skills mode was previously used
|
||||
# and persist that result as well.
|
||||
registered_skills = self._register_skills(manifest, dest_dir)
|
||||
self.registry.update(manifest.id, {
|
||||
|
||||
@@ -43,16 +43,6 @@ class TestCliDiagnosticFormatting:
|
||||
|
||||
|
||||
class TestInitIntegrationFlag:
|
||||
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(tmp_path / "test-project"), "--ai", "claude", "--integration", "copilot",
|
||||
])
|
||||
assert result.exit_code != 0
|
||||
assert "mutually exclusive" in result.output
|
||||
|
||||
def test_unknown_integration_rejected(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
@@ -131,7 +121,7 @@ class TestInitIntegrationFlag:
|
||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
||||
|
||||
def test_ai_copilot_auto_promotes(self, tmp_path):
|
||||
def test_integration_copilot_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "promote-test"
|
||||
@@ -141,66 +131,13 @@ class TestInitIntegrationFlag:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "copilot", "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
|
||||
def test_ai_emits_deprecation_warning_with_integration_replacement(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "warn-ai"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "copilot", "--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Deprecation Warning" in normalized_output
|
||||
assert "--ai" in normalized_output
|
||||
assert "deprecated" in normalized_output
|
||||
assert "no longer be available" in normalized_output
|
||||
assert "0.10.0" in normalized_output
|
||||
assert "--integration copilot" in normalized_output
|
||||
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
|
||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
||||
|
||||
def test_ai_generic_warning_suggests_integration_options_equivalent(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "warn-generic"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "generic", "--ai-commands-dir", ".myagent/commands",
|
||||
"--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Deprecation Warning" in normalized_output
|
||||
assert "--integration generic" in normalized_output
|
||||
assert "--integration-options" in normalized_output
|
||||
assert ".myagent/commands" in normalized_output
|
||||
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
|
||||
assert (project / ".myagent" / "commands" / "speckit.plan.md").exists()
|
||||
|
||||
def test_init_optional_preset_failure_reports_target_and_continues(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
@@ -237,7 +174,7 @@ class TestInitIntegrationFlag:
|
||||
assert "Continuing without the optional preset" in normalized
|
||||
assert "Project ready" in normalized
|
||||
|
||||
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
|
||||
def test_integration_claude_here_preserves_preexisting_commands(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -255,7 +192,7 @@ class TestInitIntegrationFlag:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--force", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -800,7 +737,7 @@ class TestGitExtensionAutoInstall:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -838,7 +775,7 @@ class TestGitExtensionAutoInstall:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -862,7 +799,7 @@ class TestGitExtensionAutoInstall:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--no-git", "--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -889,7 +826,7 @@ class TestGitExtensionAutoInstall:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -915,7 +852,7 @@ class TestGitExtensionAutoInstall:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
|
||||
@@ -29,19 +29,19 @@ class TestAgyIntegration(SkillsIntegrationTests):
|
||||
assert i.config["install_url"] == "https://antigravity.google/"
|
||||
|
||||
|
||||
class TestAgyAutoPromote:
|
||||
"""--ai agy auto-promotes to integration path."""
|
||||
class TestAgyInitFlow:
|
||||
"""--integration agy creates expected files."""
|
||||
|
||||
def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path):
|
||||
"""--ai agy should work the same as --integration agy."""
|
||||
def test_integration_agy_creates_skills(self, tmp_path):
|
||||
"""--integration agy should create skills directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0, f"init --ai agy failed: {result.output}"
|
||||
assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_agy_setup_warning(self, tmp_path):
|
||||
@@ -52,7 +52,7 @@ class TestAgyAutoPromote:
|
||||
# Click >= 8.2 separates stdout and stderr natively
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj2"
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
||||
|
||||
@@ -179,9 +179,9 @@ class MarkdownIntegrationTests:
|
||||
assert "<!-- SPECKIT END -->" not in remaining
|
||||
assert "# My Rules" in remaining
|
||||
|
||||
# -- CLI auto-promote -------------------------------------------------
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
def test_ai_flag_auto_promotes(self, tmp_path):
|
||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -192,15 +192,15 @@ class MarkdownIntegrationTests:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
|
||||
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -312,9 +312,9 @@ class SkillsIntegrationTests:
|
||||
assert "<!-- SPECKIT END -->" not in remaining
|
||||
assert "# My Rules" in remaining
|
||||
|
||||
# -- CLI auto-promote -------------------------------------------------
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
def test_ai_flag_auto_promotes(self, tmp_path):
|
||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -325,15 +325,15 @@ class SkillsIntegrationTests:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
skills_dir = i.skills_dest(project)
|
||||
assert skills_dir.is_dir(), f"--ai {self.KEY} did not create skills directory"
|
||||
assert skills_dir.is_dir(), f"--integration {self.KEY} did not create skills directory"
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -388,9 +388,9 @@ class TomlIntegrationTests:
|
||||
assert "<!-- SPECKIT END -->" not in remaining
|
||||
assert "# My Rules" in remaining
|
||||
|
||||
# -- CLI auto-promote -------------------------------------------------
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
def test_ai_flag_auto_promotes(self, tmp_path):
|
||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -405,7 +405,7 @@ class TomlIntegrationTests:
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--ai",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
@@ -416,10 +416,10 @@ class TomlIntegrationTests:
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
|
||||
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -267,9 +267,9 @@ class YamlIntegrationTests:
|
||||
assert "<!-- SPECKIT END -->" not in remaining
|
||||
assert "# My Rules" in remaining
|
||||
|
||||
# -- CLI auto-promote -------------------------------------------------
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
def test_ai_flag_auto_promotes(self, tmp_path):
|
||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -284,7 +284,7 @@ class YamlIntegrationTests:
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--ai",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
@@ -295,10 +295,10 @@ class YamlIntegrationTests:
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
|
||||
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -118,7 +118,7 @@ class TestClaudeIntegration:
|
||||
assert b"<!-- SPECKIT" not in remaining
|
||||
assert b"# CLAUDE.md" in remaining
|
||||
|
||||
def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path):
|
||||
def test_integration_flag_creates_skill_files_cli(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -133,7 +133,7 @@ class TestClaudeIntegration:
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--ai",
|
||||
"--integration",
|
||||
"claude",
|
||||
"--script",
|
||||
"sh",
|
||||
@@ -234,7 +234,7 @@ class TestClaudeIntegration:
|
||||
assert init_options["integration"] == "claude"
|
||||
|
||||
def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
|
||||
"""Claude init should succeed even without install_ai_skills."""
|
||||
"""Claude init should succeed even without install_skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -243,7 +243,7 @@ class TestClaudeIntegration:
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
|
||||
["init", str(target), "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -14,19 +14,19 @@ class TestCodexIntegration(SkillsIntegrationTests):
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
|
||||
class TestCodexAutoPromote:
|
||||
"""--ai codex auto-promotes to integration path."""
|
||||
class TestCodexInitFlow:
|
||||
"""--integration codex creates expected files."""
|
||||
|
||||
def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path):
|
||||
"""--ai codex should work the same as --integration codex."""
|
||||
def test_integration_codex_creates_skills(self, tmp_path):
|
||||
"""--integration codex should create skills in .agents/skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0, f"init --ai codex failed: {result.output}"
|
||||
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
|
||||
|
||||
@@ -92,19 +92,19 @@ class TestCursorMdcFrontmatter:
|
||||
assert not ctx_path.exists()
|
||||
|
||||
|
||||
class TestCursorAgentAutoPromote:
|
||||
"""--ai cursor-agent auto-promotes to integration path."""
|
||||
class TestCursorAgentInitFlow:
|
||||
"""--integration cursor-agent creates expected files."""
|
||||
|
||||
def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path):
|
||||
"""--ai cursor-agent should work the same as --integration cursor-agent."""
|
||||
def test_integration_cursor_agent_creates_skills(self, tmp_path):
|
||||
"""--integration cursor-agent should create skills in .cursor/skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--ai", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}"
|
||||
assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
|
||||
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ class TestCursorAgentCliDispatch:
|
||||
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``)
|
||||
``specify init --integration 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
|
||||
|
||||
@@ -56,11 +56,11 @@ class TestDevinBuildExecArgs:
|
||||
assert args == ["devin", "-p", "hi", "--model", "claude-sonnet-4"]
|
||||
|
||||
|
||||
class TestDevinAutoPromote:
|
||||
"""--ai devin auto-promotes to integration path."""
|
||||
class TestDevinInitFlow:
|
||||
"""--integration devin creates expected files."""
|
||||
|
||||
def test_ai_devin_without_ai_skills_auto_promotes(self, tmp_path):
|
||||
"""--ai devin should work the same as --integration devin."""
|
||||
def test_integration_devin_creates_skills(self, tmp_path):
|
||||
"""--integration devin should create skills directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -68,8 +68,8 @@ class TestDevinAutoPromote:
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--ai", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"],
|
||||
["init", str(target), "--integration", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init --ai devin failed: {result.output}"
|
||||
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
|
||||
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
@@ -245,7 +245,7 @@ class TestGenericIntegration:
|
||||
# -- CLI --------------------------------------------------------------
|
||||
|
||||
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
|
||||
"""--integration generic without --ai-commands-dir should fail."""
|
||||
"""--integration generic without --integration-options should fail."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
runner = CliRunner()
|
||||
@@ -253,8 +253,7 @@ class TestGenericIntegration:
|
||||
"init", str(tmp_path / "test-generic"), "--integration", "generic",
|
||||
"--script", "sh", "--no-git",
|
||||
])
|
||||
# Generic requires --commands-dir / --ai-commands-dir
|
||||
# The integration path validates via setup()
|
||||
# Generic requires --commands-dir via --integration-options
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
@@ -270,7 +269,7 @@ class TestGenericIntegration:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--ai-commands-dir", ".myagent/commands",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -281,7 +280,7 @@ class TestGenericIntegration:
|
||||
assert ext_cfg.get("context_file") == "AGENTS.md"
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script sh."""
|
||||
"""Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -292,7 +291,7 @@ class TestGenericIntegration:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--ai-commands-dir", ".myagent/commands",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
@@ -345,7 +344,7 @@ class TestGenericIntegration:
|
||||
)
|
||||
|
||||
def test_complete_file_inventory_ps(self, tmp_path):
|
||||
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script ps."""
|
||||
"""Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script ps."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -356,7 +355,7 @@ class TestGenericIntegration:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--ai-commands-dir", ".myagent/commands",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "ps", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
|
||||
@@ -326,12 +326,11 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
)
|
||||
|
||||
|
||||
class TestHermesAutoPromote:
|
||||
"""--ai hermes auto-promotes to integration path."""
|
||||
class TestHermesInitFlow:
|
||||
"""--integration hermes creates expected files."""
|
||||
|
||||
def test_ai_hermes_without_ai_skills_auto_promotes(self, tmp_path, monkeypatch):
|
||||
"""--ai hermes should work the same as --integration hermes,
|
||||
creating global skills and a local marker."""
|
||||
def test_integration_hermes_creates_global_skills(self, tmp_path, monkeypatch):
|
||||
"""--integration hermes should create global skills and a local marker."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
@@ -342,13 +341,13 @@ class TestHermesAutoPromote:
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target),
|
||||
"--ai", "hermes",
|
||||
"--integration", "hermes",
|
||||
"--no-git",
|
||||
"--ignore-agent-tools",
|
||||
"--script", "sh",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, f"init --ai hermes failed: {result.output}"
|
||||
assert result.exit_code == 0, f"init --integration hermes failed: {result.output}"
|
||||
# Skills should be in global ~/.hermes/skills/
|
||||
assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
# Local marker should exist
|
||||
|
||||
@@ -137,7 +137,7 @@ class TestKimiNextSteps:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "kimi", "--no-git",
|
||||
"init", "--here", "--integration", "kimi", "--no-git",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
|
||||
@@ -123,15 +123,15 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
|
||||
)
|
||||
|
||||
|
||||
class TestKiroAlias:
|
||||
"""--ai kiro alias normalizes to kiro-cli and auto-promotes."""
|
||||
class TestKiroIntegration:
|
||||
"""--integration kiro-cli creates expected files."""
|
||||
|
||||
def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path):
|
||||
"""--ai kiro should normalize to canonical kiro-cli and auto-promote."""
|
||||
def test_integration_kiro_cli_creates_files(self, tmp_path):
|
||||
"""--integration kiro-cli should create files in .kiro/prompts."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
target = tmp_path / "kiro-alias-proj"
|
||||
target = tmp_path / "kiro-proj"
|
||||
target.mkdir()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
@@ -139,7 +139,7 @@ class TestKiroAlias:
|
||||
os.chdir(target)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--ai", "kiro",
|
||||
"init", "--here", "--integration", "kiro-cli",
|
||||
"--ignore-agent-tools", "--script", "sh", "--no-git",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
|
||||
@@ -294,11 +294,11 @@ class TestRovodevIntegration:
|
||||
assert init_options.get("ai_skills") is True
|
||||
assert init_options.get("script") == "sh"
|
||||
|
||||
def test_ai_flag_auto_promotes_to_integration(self, tmp_path):
|
||||
"""``--ai rovodev`` should reach the same end-state as ``--integration rovodev``."""
|
||||
project = tmp_path / "rovodev-ai"
|
||||
def test_integration_flag_creates_expected_files(self, tmp_path):
|
||||
"""``--integration rovodev`` should create all expected rovodev files."""
|
||||
project = tmp_path / "rovodev-int"
|
||||
project.mkdir()
|
||||
result = _run_init(project, "--ai", "rovodev")
|
||||
result = _run_init(project, "--integration", "rovodev")
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (project / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (project / ".rovodev" / "prompts.yml").exists()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP
|
||||
from specify_cli import AGENT_CONFIG
|
||||
from specify_cli.extensions import CommandRegistrar
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -39,13 +39,6 @@ class TestAgentConfigConsistency:
|
||||
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
|
||||
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
|
||||
|
||||
def test_init_ai_help_includes_roo_and_kiro_alias(self):
|
||||
"""CLI help text for --ai should stay in sync with agent config and alias guidance."""
|
||||
assert "roo" in AI_ASSISTANT_HELP
|
||||
for alias, target in AI_ASSISTANT_ALIASES.items():
|
||||
assert alias in AI_ASSISTANT_HELP
|
||||
assert target in AI_ASSISTANT_HELP
|
||||
|
||||
def test_devcontainer_kiro_installer_uses_pinned_checksum(self):
|
||||
"""Devcontainer installer should always verify Kiro installer via pinned SHA256."""
|
||||
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(
|
||||
@@ -80,9 +73,9 @@ class TestAgentConfigConsistency:
|
||||
assert cfg["args"] == "{{args}}"
|
||||
assert cfg["extension"] == ".toml"
|
||||
|
||||
def test_ai_help_includes_tabnine(self):
|
||||
"""CLI help text for --ai should include tabnine."""
|
||||
assert "tabnine" in AI_ASSISTANT_HELP
|
||||
def test_agent_config_includes_tabnine(self):
|
||||
"""AGENT_CONFIG should include tabnine."""
|
||||
assert "tabnine" in AGENT_CONFIG
|
||||
|
||||
# --- Kimi Code CLI consistency checks ---
|
||||
|
||||
@@ -102,9 +95,9 @@ class TestAgentConfigConsistency:
|
||||
assert kimi_cfg["dir"] == ".kimi/skills"
|
||||
assert kimi_cfg["extension"] == "/SKILL.md"
|
||||
|
||||
def test_ai_help_includes_kimi(self):
|
||||
"""CLI help text for --ai should include kimi."""
|
||||
assert "kimi" in AI_ASSISTANT_HELP
|
||||
def test_agent_config_includes_kimi(self):
|
||||
"""AGENT_CONFIG should include kimi."""
|
||||
assert "kimi" in AGENT_CONFIG
|
||||
|
||||
# --- Trae IDE consistency checks ---
|
||||
|
||||
@@ -126,9 +119,9 @@ class TestAgentConfigConsistency:
|
||||
assert trae_cfg["args"] == "$ARGUMENTS"
|
||||
assert trae_cfg["extension"] == "/SKILL.md"
|
||||
|
||||
def test_ai_help_includes_trae(self):
|
||||
"""CLI help text for --ai should include trae."""
|
||||
assert "trae" in AI_ASSISTANT_HELP
|
||||
def test_agent_config_includes_trae(self):
|
||||
"""AGENT_CONFIG should include trae."""
|
||||
assert "trae" in AGENT_CONFIG
|
||||
|
||||
# --- Pi Coding Agent consistency checks ---
|
||||
|
||||
@@ -151,9 +144,9 @@ class TestAgentConfigConsistency:
|
||||
assert pi_cfg["args"] == "$ARGUMENTS"
|
||||
assert pi_cfg["extension"] == ".md"
|
||||
|
||||
def test_ai_help_includes_pi(self):
|
||||
"""CLI help text for --ai should include pi."""
|
||||
assert "pi" in AI_ASSISTANT_HELP
|
||||
def test_agent_config_includes_pi(self):
|
||||
"""AGENT_CONFIG should include pi."""
|
||||
assert "pi" in AGENT_CONFIG
|
||||
|
||||
# --- iFlow CLI consistency checks ---
|
||||
|
||||
@@ -173,9 +166,9 @@ class TestAgentConfigConsistency:
|
||||
assert cfg["iflow"]["format"] == "markdown"
|
||||
assert cfg["iflow"]["args"] == "$ARGUMENTS"
|
||||
|
||||
def test_ai_help_includes_iflow(self):
|
||||
"""CLI help text for --ai should include iflow."""
|
||||
assert "iflow" in AI_ASSISTANT_HELP
|
||||
def test_agent_config_includes_iflow(self):
|
||||
"""AGENT_CONFIG should include iflow."""
|
||||
assert "iflow" in AGENT_CONFIG
|
||||
|
||||
# --- Goose consistency checks ---
|
||||
|
||||
@@ -195,9 +188,9 @@ class TestAgentConfigConsistency:
|
||||
assert cfg["goose"]["format"] == "yaml"
|
||||
assert cfg["goose"]["args"] == "{{args}}"
|
||||
|
||||
def test_ai_help_includes_goose(self):
|
||||
"""CLI help text for --ai should include goose."""
|
||||
assert "goose" in AI_ASSISTANT_HELP
|
||||
def test_agent_config_includes_goose(self):
|
||||
"""AGENT_CONFIG should include goose."""
|
||||
assert "goose" in AGENT_CONFIG
|
||||
|
||||
# --- invoke_separator propagation checks ---
|
||||
|
||||
@@ -304,6 +297,6 @@ class TestAgentConfigConsistency:
|
||||
assert rovodev_cfg["args"] == "$ARGUMENTS"
|
||||
assert rovodev_cfg["extension"] == "/SKILL.md"
|
||||
|
||||
def test_ai_help_includes_rovodev(self):
|
||||
"""CLI help text for --ai should include rovodev."""
|
||||
assert "rovodev" in AI_ASSISTANT_HELP
|
||||
def test_agent_config_includes_rovodev(self):
|
||||
"""AGENT_CONFIG should include rovodev."""
|
||||
assert "rovodev" in AGENT_CONFIG
|
||||
|
||||
@@ -36,7 +36,7 @@ class TestSaveBranchNumbering:
|
||||
|
||||
project_dir = tmp_path / "proj"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(project_dir), "--integration", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
|
||||
@@ -51,7 +51,7 @@ class TestBranchNumberingValidation:
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"])
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"])
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid --branch-numbering" in result.output
|
||||
|
||||
@@ -60,7 +60,7 @@ class TestBranchNumberingValidation:
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
assert result.exit_code == 0
|
||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||
|
||||
@@ -69,6 +69,6 @@ class TestBranchNumberingValidation:
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||
assert result.exit_code == 0
|
||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||
|
||||
@@ -16,14 +16,10 @@ def test_commands_init_importable():
|
||||
def test_agent_config_importable():
|
||||
from specify_cli._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
AI_ASSISTANT_ALIASES,
|
||||
AI_ASSISTANT_HELP,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert isinstance(AI_ASSISTANT_ALIASES, dict)
|
||||
assert isinstance(AI_ASSISTANT_HELP, str)
|
||||
assert DEFAULT_INIT_INTEGRATION == "copilot"
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Unit tests for extension skill auto-registration.
|
||||
|
||||
Tests cover:
|
||||
- SKILL.md generation when --ai-skills was used during init
|
||||
- SKILL.md generation when skills mode was used during init
|
||||
- No skills created when ai_skills not active
|
||||
- SKILL.md content correctness
|
||||
- Existing user-modified skills not overwritten
|
||||
@@ -162,7 +162,7 @@ def extension_dir(temp_dir):
|
||||
|
||||
@pytest.fixture
|
||||
def skills_project(project_dir):
|
||||
"""Create a project with --ai-skills enabled and skills directory."""
|
||||
"""Create a project with skills mode enabled and skills directory."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
return project_dir, skills_dir
|
||||
@@ -170,7 +170,7 @@ def skills_project(project_dir):
|
||||
|
||||
@pytest.fixture
|
||||
def no_skills_project(project_dir):
|
||||
"""Create a project without --ai-skills."""
|
||||
"""Create a project without skills mode."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||
return project_dir
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from tests.conftest import strip_ansi
|
||||
from specify_cli.extensions import (
|
||||
CatalogEntry,
|
||||
CORE_COMMAND_NAMES,
|
||||
DEFAULT_HOOK_PRIORITY,
|
||||
ExtensionManifest,
|
||||
ExtensionRegistry,
|
||||
ExtensionManager,
|
||||
@@ -190,6 +191,12 @@ class TestNormalizePriority:
|
||||
assert normalize_priority(None, default=20) == 20
|
||||
assert normalize_priority("invalid", default=1) == 1
|
||||
|
||||
def test_boolean_returns_default(self):
|
||||
"""Booleans fall back to the default rather than acting as int 0/1."""
|
||||
assert normalize_priority(True) == 10
|
||||
assert normalize_priority(False) == 10
|
||||
assert normalize_priority(True, default=5) == 5
|
||||
|
||||
|
||||
# ===== ExtensionManifest Tests =====
|
||||
|
||||
@@ -458,6 +465,137 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hook_single_mapping_still_accepted(self, extension_dir):
|
||||
"""Existing single-mapping hook manifests parse unchanged (regression)."""
|
||||
manifest_path = extension_dir / "extension.yml"
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
assert "after_tasks" in manifest.hooks
|
||||
assert isinstance(manifest.hooks["after_tasks"], dict)
|
||||
assert manifest.hooks["after_tasks"]["command"] == "speckit.test-ext.hello"
|
||||
|
||||
def test_hook_list_of_mappings_accepted(self, temp_dir, valid_manifest_data):
|
||||
"""A hook event may be configured as a list of mappings."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"].append({
|
||||
"name": "speckit.test-ext.bye",
|
||||
"file": "commands/bye.md",
|
||||
"description": "Second test command",
|
||||
})
|
||||
valid_manifest_data["hooks"]["after_tasks"] = [
|
||||
{"command": "speckit.test-ext.hello", "description": "first"},
|
||||
{"command": "speckit.test-ext.bye", "description": "second"},
|
||||
]
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
entries = manifest.hooks["after_tasks"]
|
||||
assert isinstance(entries, list)
|
||||
assert [e["command"] for e in entries] == [
|
||||
"speckit.test-ext.hello",
|
||||
"speckit.test-ext.bye",
|
||||
]
|
||||
|
||||
def test_hook_list_with_non_mapping_entry_rejected(self, temp_dir, valid_manifest_data):
|
||||
"""A list entry that is not a mapping must raise ValidationError."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["hooks"]["after_tasks"] = [
|
||||
{"command": "speckit.test-ext.hello"},
|
||||
"not-a-mapping",
|
||||
]
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Invalid hook 'after_tasks': expected a mapping or list of mappings",
|
||||
):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hook_list_command_refs_normalized(self, temp_dir, valid_manifest_data):
|
||||
"""Alias-form command refs are lifted to canonical form for every entry
|
||||
in a list hook, each emitting a warning."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"]["commands"].append({
|
||||
"name": "speckit.test-ext.bye",
|
||||
"file": "commands/bye.md",
|
||||
"description": "Second test command",
|
||||
})
|
||||
valid_manifest_data["hooks"]["after_tasks"] = [
|
||||
{"command": "test-ext.hello"},
|
||||
{"command": "test-ext.bye"},
|
||||
]
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
assert [e["command"] for e in manifest.hooks["after_tasks"]] == [
|
||||
"speckit.test-ext.hello",
|
||||
"speckit.test-ext.bye",
|
||||
]
|
||||
lifted = [w for w in manifest.warnings if "updated to canonical form" in w]
|
||||
assert len(lifted) == 2
|
||||
|
||||
def test_hook_empty_list_rejected(self, temp_dir, valid_manifest_data):
|
||||
"""An empty list for a hook event is rejected rather than silently
|
||||
registering nothing."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["hooks"]["after_tasks"] = []
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="must contain at least one entry"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hook_priority_field_validation(self, temp_dir, valid_manifest_data):
|
||||
"""Hook entry ``priority`` must be a positive integer when provided."""
|
||||
import yaml
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
|
||||
valid_manifest_data["hooks"]["after_tasks"] = {
|
||||
"command": "speckit.test-ext.hello",
|
||||
"priority": "high",
|
||||
}
|
||||
with open(manifest_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
with pytest.raises(ValidationError, match="invalid 'priority'.*integer"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
valid_manifest_data["hooks"]["after_tasks"]["priority"] = 0
|
||||
with open(manifest_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
with pytest.raises(ValidationError, match="invalid 'priority'.*>= 1"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
# bool is a subclass of int, so it must be rejected explicitly.
|
||||
valid_manifest_data["hooks"]["after_tasks"]["priority"] = True
|
||||
with open(manifest_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
with pytest.raises(ValidationError, match="invalid 'priority'.*integer"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
valid_manifest_data["hooks"]["after_tasks"]["priority"] = 5
|
||||
with open(manifest_path, 'w', encoding="utf-8") as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
assert manifest.hooks["after_tasks"]["priority"] == 5
|
||||
|
||||
def test_manifest_hash(self, extension_dir):
|
||||
"""Test manifest hash calculation."""
|
||||
manifest_path = extension_dir / "extension.yml"
|
||||
@@ -4906,6 +5044,405 @@ class TestExtensionPriorityBackwardsCompatibility:
|
||||
assert result[2][0] == "ext-low-priority"
|
||||
|
||||
|
||||
class _StubManifest(ExtensionManifest):
|
||||
"""ExtensionManifest stub for HookExecutor tests.
|
||||
|
||||
Subclasses the real manifest so it satisfies ``register_hooks``'s type
|
||||
while bypassing the file-based parsing/validation pipeline. The inherited
|
||||
``id`` and ``hooks`` properties read from ``data``, so populating ``data``
|
||||
is enough.
|
||||
"""
|
||||
|
||||
def __init__(self, ext_id: str, hooks: dict):
|
||||
self.data = {"extension": {"id": ext_id}, "hooks": hooks}
|
||||
|
||||
|
||||
class TestHookExecutorRegistration:
|
||||
"""Tests for HookExecutor.register_hooks / get_hooks_for_event with
|
||||
multi-entry hook events and per-entry priority ordering."""
|
||||
|
||||
def test_register_hooks_single_mapping_back_compat(self, project_dir):
|
||||
"""Single-mapping form continues to register exactly one entry with
|
||||
default priority."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
|
||||
)
|
||||
|
||||
config = executor.get_project_config()
|
||||
entries = config["hooks"]["after_tasks"]
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["extension"] == "ext-a"
|
||||
assert entries[0]["command"] == "speckit.ext-a.go"
|
||||
assert entries[0]["priority"] == DEFAULT_HOOK_PRIORITY
|
||||
|
||||
def test_register_hooks_multiple_entries_same_event(self, project_dir):
|
||||
"""A list of mappings registers each entry under the same event."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-a",
|
||||
{
|
||||
"after_tasks": [
|
||||
{"command": "speckit.ext-a.first", "description": "1st"},
|
||||
{"command": "speckit.ext-a.second", "description": "2nd"},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
entries = executor.get_project_config()["hooks"]["after_tasks"]
|
||||
assert len(entries) == 2
|
||||
assert [e["command"] for e in entries] == [
|
||||
"speckit.ext-a.first",
|
||||
"speckit.ext-a.second",
|
||||
]
|
||||
assert all(e["extension"] == "ext-a" for e in entries)
|
||||
|
||||
def test_register_hooks_dedup_on_extension_and_command(self, project_dir):
|
||||
"""Re-registering the same (extension, command) updates in place
|
||||
rather than appending a duplicate entry."""
|
||||
executor = HookExecutor(project_dir)
|
||||
manifest = _StubManifest(
|
||||
"ext-a",
|
||||
{
|
||||
"after_tasks": [
|
||||
{"command": "speckit.ext-a.first", "description": "v1"},
|
||||
{"command": "speckit.ext-a.second", "description": "v1"},
|
||||
]
|
||||
},
|
||||
)
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
manifest.hooks["after_tasks"][0]["description"] = "v2"
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
entries = executor.get_project_config()["hooks"]["after_tasks"]
|
||||
assert len(entries) == 2
|
||||
first = next(e for e in entries if e["command"] == "speckit.ext-a.first")
|
||||
assert first["description"] == "v2"
|
||||
|
||||
def test_register_hooks_shape_change_removes_orphans(self, project_dir):
|
||||
"""Reinstalling with a shorter hook shape (list → single mapping, or a
|
||||
shrunk list) purges the dropped commands instead of leaving orphans."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-a",
|
||||
{
|
||||
"after_tasks": [
|
||||
{"command": "speckit.ext-a.first"},
|
||||
{"command": "speckit.ext-a.second"},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.first"}})
|
||||
)
|
||||
|
||||
entries = executor.get_project_config()["hooks"]["after_tasks"]
|
||||
assert [e["command"] for e in entries] == ["speckit.ext-a.first"]
|
||||
|
||||
def test_register_hooks_single_to_list_reinstall_adds_entries(self, project_dir):
|
||||
"""Reinstalling a single-mapping hook as a list adds the new entries."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.first"}})
|
||||
)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-a",
|
||||
{
|
||||
"after_tasks": [
|
||||
{"command": "speckit.ext-a.first"},
|
||||
{"command": "speckit.ext-a.second"},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
entries = executor.get_project_config()["hooks"]["after_tasks"]
|
||||
assert [e["command"] for e in entries] == [
|
||||
"speckit.ext-a.first",
|
||||
"speckit.ext-a.second",
|
||||
]
|
||||
|
||||
def test_register_hooks_skips_entry_without_command(self, project_dir):
|
||||
"""An entry lacking a command is skipped (defensive; validated
|
||||
manifests never reach this state)."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-a",
|
||||
{
|
||||
"after_tasks": [
|
||||
{"command": "speckit.ext-a.go"},
|
||||
{"optional": True},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
entries = executor.get_project_config()["hooks"]["after_tasks"]
|
||||
assert [e["command"] for e in entries] == ["speckit.ext-a.go"]
|
||||
|
||||
def test_register_hooks_skips_non_dict_entry(self, project_dir):
|
||||
"""A non-dict entry in a hook list is skipped rather than crashing
|
||||
(defensive; validated manifests never reach this state)."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-a",
|
||||
{"after_tasks": [{"command": "speckit.ext-a.go"}, "not-a-mapping"]},
|
||||
)
|
||||
)
|
||||
|
||||
entries = executor.get_project_config()["hooks"]["after_tasks"]
|
||||
assert [e["command"] for e in entries] == ["speckit.ext-a.go"]
|
||||
|
||||
def test_register_hooks_purges_dropped_event_orphans(self, project_dir):
|
||||
"""Re-registering without an event it previously declared purges this
|
||||
extension's entries from that event, scoped to this extension."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-a",
|
||||
{
|
||||
"after_tasks": {"command": "speckit.ext-a.tasks"},
|
||||
"after_plan": {"command": "speckit.ext-a.plan"},
|
||||
"after_implement": {"command": "speckit.ext-a.impl"},
|
||||
},
|
||||
)
|
||||
)
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-b", {"after_plan": {"command": "speckit.ext-b.plan"}})
|
||||
)
|
||||
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.tasks"}})
|
||||
)
|
||||
|
||||
hooks = executor.get_project_config()["hooks"]
|
||||
assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-a.tasks"]
|
||||
assert [e["command"] for e in hooks["after_plan"]] == ["speckit.ext-b.plan"]
|
||||
assert "after_implement" not in hooks
|
||||
|
||||
def test_register_hooks_dropping_all_hooks_purges_orphans(self, project_dir):
|
||||
"""Reinstalling with an empty hooks mapping still purges this
|
||||
extension's entries, scoped to this extension."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
|
||||
)
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.go"}})
|
||||
)
|
||||
|
||||
executor.register_hooks(_StubManifest("ext-a", {}))
|
||||
|
||||
hooks = executor.get_project_config()["hooks"]
|
||||
assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-b.go"]
|
||||
|
||||
def test_register_hooks_empty_hooks_purge_survives_corrupt_entry(self, project_dir):
|
||||
"""A corrupt non-dict entry already on disk does not break the
|
||||
empty-hooks orphan purge; it is dropped and valid entries survive."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
|
||||
)
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.go"}})
|
||||
)
|
||||
config = executor.get_project_config()
|
||||
config["hooks"]["after_tasks"].append("corrupt-non-dict-entry")
|
||||
executor.save_project_config(config)
|
||||
|
||||
executor.register_hooks(_StubManifest("ext-a", {}))
|
||||
|
||||
hooks = executor.get_project_config()["hooks"]
|
||||
assert [e["command"] for e in hooks["after_tasks"]] == ["speckit.ext-b.go"]
|
||||
|
||||
def test_register_hooks_duplicate_command_moves_to_end(self, project_dir):
|
||||
"""A command repeated in one manifest keeps the last value and the last
|
||||
insertion position, so equal-priority tie order is 'last wins'."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-a",
|
||||
{
|
||||
"after_tasks": [
|
||||
{"command": "speckit.ext-a.dup", "description": "first"},
|
||||
{"command": "speckit.ext-a.other"},
|
||||
{"command": "speckit.ext-a.dup", "description": "last"},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
entries = executor.get_project_config()["hooks"]["after_tasks"]
|
||||
assert [e["command"] for e in entries] == [
|
||||
"speckit.ext-a.other",
|
||||
"speckit.ext-a.dup",
|
||||
]
|
||||
assert entries[-1]["description"] == "last"
|
||||
|
||||
def test_register_hooks_preserves_other_extensions(self, project_dir):
|
||||
"""Re-registering one extension must not disturb another extension's
|
||||
entries on the same event."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
|
||||
)
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.go"}})
|
||||
)
|
||||
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-a", {"after_tasks": {"command": "speckit.ext-a.go"}})
|
||||
)
|
||||
|
||||
entries = executor.get_project_config()["hooks"]["after_tasks"]
|
||||
assert sorted(e["extension"] for e in entries) == ["ext-a", "ext-b"]
|
||||
|
||||
def test_get_hooks_for_event_sorts_by_priority(self, project_dir):
|
||||
"""Returned entries are sorted by priority ascending; equal priorities
|
||||
preserve insertion order via stable sort."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-a",
|
||||
{
|
||||
"after_tasks": [
|
||||
{"command": "speckit.ext-a.mid", "priority": 10},
|
||||
{"command": "speckit.ext-a.first", "priority": 1},
|
||||
{"command": "speckit.ext-a.late", "priority": 20},
|
||||
{"command": "speckit.ext-a.mid-tied", "priority": 10},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
ordered = executor.get_hooks_for_event("after_tasks")
|
||||
assert [e["command"] for e in ordered] == [
|
||||
"speckit.ext-a.first",
|
||||
"speckit.ext-a.mid",
|
||||
"speckit.ext-a.mid-tied",
|
||||
"speckit.ext-a.late",
|
||||
]
|
||||
|
||||
def test_get_hooks_for_event_orders_across_extensions(self, project_dir):
|
||||
"""Priority controls execution order across extensions regardless of
|
||||
install order (Issue #2378 use case)."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-report",
|
||||
{"after_plan": {"command": "speckit.ext-report.run", "priority": 20}},
|
||||
)
|
||||
)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-verify",
|
||||
{"after_plan": {"command": "speckit.ext-verify.run", "priority": 5}},
|
||||
)
|
||||
)
|
||||
|
||||
ordered = executor.get_hooks_for_event("after_plan")
|
||||
assert [e["command"] for e in ordered] == [
|
||||
"speckit.ext-verify.run",
|
||||
"speckit.ext-report.run",
|
||||
]
|
||||
|
||||
def test_get_hooks_for_event_treats_missing_priority_as_default(self, project_dir):
|
||||
"""Entries persisted before priority was introduced should be sorted
|
||||
as if their priority equaled DEFAULT_HOOK_PRIORITY."""
|
||||
executor = HookExecutor(project_dir)
|
||||
# Legacy on-disk entry with no priority key.
|
||||
# register_hooks now always sets one, so write this state directly.
|
||||
executor.save_project_config({
|
||||
"installed": [],
|
||||
"settings": {"auto_execute_hooks": True},
|
||||
"hooks": {
|
||||
"after_tasks": [
|
||||
{
|
||||
"extension": "legacy",
|
||||
"command": "speckit.legacy.go",
|
||||
"enabled": True,
|
||||
},
|
||||
{
|
||||
"extension": "newer",
|
||||
"command": "speckit.newer.first",
|
||||
"enabled": True,
|
||||
"priority": 1,
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
ordered = executor.get_hooks_for_event("after_tasks")
|
||||
assert [e["command"] for e in ordered] == [
|
||||
"speckit.newer.first",
|
||||
"speckit.legacy.go",
|
||||
]
|
||||
|
||||
def test_get_hooks_for_event_tolerates_corrupted_priority(self, project_dir):
|
||||
"""A corrupted on-disk ``priority`` (non-numeric, None, or < 1) is
|
||||
normalized to the default instead of raising during sort."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.save_project_config({
|
||||
"installed": [],
|
||||
"settings": {"auto_execute_hooks": True},
|
||||
"hooks": {
|
||||
"after_tasks": [
|
||||
{
|
||||
"extension": "corrupt",
|
||||
"command": "speckit.corrupt.go",
|
||||
"enabled": True,
|
||||
"priority": "not-a-number",
|
||||
},
|
||||
{
|
||||
"extension": "early",
|
||||
"command": "speckit.early.go",
|
||||
"enabled": True,
|
||||
"priority": 1,
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
ordered = executor.get_hooks_for_event("after_tasks")
|
||||
assert [e["command"] for e in ordered] == [
|
||||
"speckit.early.go",
|
||||
"speckit.corrupt.go",
|
||||
]
|
||||
|
||||
def test_unregister_hooks_removes_all_extension_entries(self, project_dir):
|
||||
"""unregister_hooks removes every entry for the extension regardless
|
||||
of how many were registered to a given event."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(
|
||||
_StubManifest(
|
||||
"ext-a",
|
||||
{
|
||||
"after_tasks": [
|
||||
{"command": "speckit.ext-a.first"},
|
||||
{"command": "speckit.ext-a.second"},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
executor.register_hooks(
|
||||
_StubManifest("ext-b", {"after_tasks": {"command": "speckit.ext-b.solo"}})
|
||||
)
|
||||
|
||||
executor.unregister_hooks("ext-a")
|
||||
|
||||
entries = executor.get_project_config()["hooks"].get("after_tasks", [])
|
||||
assert [e["extension"] for e in entries] == ["ext-b"]
|
||||
|
||||
|
||||
class TestHookInvocationRendering:
|
||||
"""Test hook invocation formatting for different agent modes."""
|
||||
|
||||
@@ -4932,7 +5469,7 @@ class TestHookInvocationRendering:
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message
|
||||
|
||||
def test_codex_hooks_render_dollar_skill_invocation(self, project_dir):
|
||||
"""Codex projects with --ai-skills should render $speckit-* invocations."""
|
||||
"""Codex projects with skills mode should render $speckit-* invocations."""
|
||||
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": True}))
|
||||
|
||||
@@ -2557,8 +2557,8 @@ class TestPresetSkills:
|
||||
return preset_dir
|
||||
|
||||
def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):
|
||||
"""When --ai-skills was used, a preset command override should update the skill."""
|
||||
# Simulate --ai-skills having been used: write init-options + create skill
|
||||
"""When skills mode was used, a preset command override should update the skill."""
|
||||
# Simulate skills mode having been used: write init-options + create skill
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify")
|
||||
@@ -2843,7 +2843,7 @@ class TestPresetSkills:
|
||||
assert "override taskstoissues body" in content
|
||||
|
||||
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||
"""When --ai-skills was NOT used, preset install should not touch skills."""
|
||||
"""When skills mode was NOT used, preset install should not touch skills."""
|
||||
self._write_init_options(project_dir, ai="qwen", ai_skills=False)
|
||||
skills_dir = project_dir / ".qwen" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
||||
@@ -2962,7 +2962,7 @@ class TestPresetSkills:
|
||||
def test_no_skills_registered_when_no_skill_dir_exists(self, project_dir, temp_dir):
|
||||
"""Skills should not be created when no existing skill dir is found."""
|
||||
self._write_init_options(project_dir, ai="claude")
|
||||
# Don't create skills dir — simulate --ai-skills never created them
|
||||
# Don't create skills dir — simulate skills mode never created them
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
install_self_test_preset(manager)
|
||||
@@ -4123,7 +4123,7 @@ class TestWrapStrategy:
|
||||
"---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n"
|
||||
)
|
||||
|
||||
# Set up skills dir (simulating --ai claude)
|
||||
# Set up skills dir (simulating --integration claude)
|
||||
skills_dir = project_dir / ".claude" / "skills"
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
skill_subdir = skills_dir / "speckit-wrap-test"
|
||||
|
||||
@@ -658,6 +658,47 @@ class TestCommandStep:
|
||||
# Claude is a SkillsIntegration so uses /speckit-specify
|
||||
assert "/speckit-specify login" in call_args[0][0][2]
|
||||
|
||||
def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch):
|
||||
"""Command preflight falls back to build_exec_args() argv[0]."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
|
||||
seen_which: list[str] = []
|
||||
|
||||
def fake_which(name: str) -> str | None:
|
||||
seen_which.append(name)
|
||||
return name if name == "/opt/claude" else None
|
||||
|
||||
step = CommandStep()
|
||||
ctx = StepContext(
|
||||
inputs={"name": "login"},
|
||||
default_integration="claude",
|
||||
project_root=str(tmp_path),
|
||||
)
|
||||
config = {
|
||||
"id": "test",
|
||||
"command": "speckit.specify",
|
||||
"input": {"args": "{{ inputs.name }}"},
|
||||
}
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = '{"result": "done"}'
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("specify_cli.workflows.steps.command.shutil.which", side_effect=fake_which), \
|
||||
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 seen_which[:2] == ["claude", "/opt/claude"]
|
||||
call_args = mock_run.call_args
|
||||
assert call_args[0][0][0] == "/opt/claude"
|
||||
assert "/speckit-specify login" in call_args[0][0][2]
|
||||
|
||||
def test_dispatch_failure_returns_failed_status(self, tmp_path):
|
||||
"""When the CLI exits non-zero, the step should fail."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -810,6 +851,46 @@ class TestPromptStep:
|
||||
assert result.output["dispatched"] is True
|
||||
assert result.output["exit_code"] == 0
|
||||
|
||||
def test_dispatch_uses_executable_override_for_fallback_preflight(self, tmp_path, monkeypatch):
|
||||
"""Prompt preflight falls back to build_exec_args() argv[0]."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
|
||||
seen_which: list[str] = []
|
||||
|
||||
def fake_which(name: str) -> str | None:
|
||||
seen_which.append(name)
|
||||
return name if name == "/opt/claude" else None
|
||||
|
||||
step = PromptStep()
|
||||
ctx = StepContext(
|
||||
default_integration="claude",
|
||||
project_root=str(tmp_path),
|
||||
)
|
||||
config = {
|
||||
"id": "ask",
|
||||
"type": "prompt",
|
||||
"prompt": "Explain this code",
|
||||
}
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "Here is the explanation"
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch("specify_cli.workflows.steps.prompt.shutil.which", side_effect=fake_which), \
|
||||
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 seen_which[:2] == ["claude", "/opt/claude"]
|
||||
call_args = mock_run.call_args
|
||||
assert call_args[0][0][0] == "/opt/claude"
|
||||
assert call_args[0][0][2] == "Explain this code"
|
||||
|
||||
def test_validate_missing_prompt(self):
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
|
||||
Reference in New Issue
Block a user