mirror of
https://github.com/github/spec-kit.git
synced 2026-07-06 22:32:13 +08:00
Compare commits
1 Commits
copilot/fi
...
v0.9.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2262359d96 |
@@ -52,19 +52,13 @@ provides:
|
|||||||
description: string
|
description: string
|
||||||
required: boolean # Default: false
|
required: boolean # Default: false
|
||||||
|
|
||||||
hooks: # Optional, event hooks. Each event accepts either form below.
|
hooks: # Optional, event hooks
|
||||||
event_name: # e.g., "after_specify", "after_plan", "after_tasks", "after_implement"
|
event_name: # e.g., "after_specify", "after_plan", "after_tasks", "after_implement"
|
||||||
command: string # Command to execute
|
command: string # Command to execute
|
||||||
priority: integer # Optional, >= 1, default 10 (lower runs first)
|
|
||||||
optional: boolean # Default: true
|
optional: boolean # Default: true
|
||||||
prompt: string # Prompt text for optional hooks
|
prompt: string # Prompt text for optional hooks
|
||||||
description: string # Hook description
|
description: string # Hook description
|
||||||
condition: string # Optional, condition expression
|
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)
|
tags: # Optional, array of tags (2-10 recommended)
|
||||||
- string
|
- string
|
||||||
@@ -115,10 +109,8 @@ defaults: # Optional, default configuration values
|
|||||||
|
|
||||||
- **Type**: object
|
- **Type**: object
|
||||||
- **Keys**: Event names (e.g., `after_specify`, `after_plan`, `after_tasks`, `after_implement`, `before_analyze`)
|
- **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
|
- **Description**: Hooks that execute at lifecycle events
|
||||||
- **Events**: Defined by core spec-kit commands
|
- **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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -543,9 +535,7 @@ Examples:
|
|||||||
|
|
||||||
### Hook Definition
|
### Hook Definition
|
||||||
|
|
||||||
Each event accepts either a single hook mapping or a list of mappings. A list registers multiple commands on the same event.
|
**In extension.yml**:
|
||||||
|
|
||||||
**Single mapping (in extension.yml)**:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
hooks:
|
hooks:
|
||||||
@@ -557,24 +547,6 @@ hooks:
|
|||||||
condition: null
|
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
|
### Hook Events
|
||||||
|
|
||||||
Standard events (defined by core):
|
Standard events (defined by core):
|
||||||
|
|||||||
@@ -206,12 +206,9 @@ Available hook points:
|
|||||||
- `before_constitution` / `after_constitution`: Before/after constitution update
|
- `before_constitution` / `after_constitution`: Before/after constitution update
|
||||||
- `before_taskstoissues` / `after_taskstoissues`: Before/after tasks-to-issues conversion
|
- `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:
|
Hook object:
|
||||||
|
|
||||||
- `command`: Command to execute (typically from `provides.commands`, but can reference any registered command)
|
- `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
|
- `optional`: If true, prompt user before executing
|
||||||
- `prompt`: Prompt text for optional hooks
|
- `prompt`: Prompt text for optional hooks
|
||||||
- `description`: Hook description
|
- `description`: Hook description
|
||||||
@@ -658,23 +655,6 @@ hooks:
|
|||||||
description: "Analyze tasks after generation"
|
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
|
## Troubleshooting
|
||||||
|
|||||||
@@ -79,14 +79,6 @@ hooks:
|
|||||||
# optional: false # Auto-execute without prompting
|
# optional: false # Auto-execute without prompting
|
||||||
# description: "Runs automatically after implementation"
|
# 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)
|
# CUSTOMIZE: Add relevant tags (2-5 recommended)
|
||||||
# Used for discovery in catalog
|
# Used for discovery in catalog
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -277,7 +277,7 @@
|
|||||||
"id": "generic",
|
"id": "generic",
|
||||||
"name": "Generic (bring your own agent)",
|
"name": "Generic (bring your own agent)",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Generic integration for any agent via --integration-options=\"--commands-dir <dir>\"",
|
"description": "Generic integration for any agent via --ai-commands-dir",
|
||||||
"author": "spec-kit-core",
|
"author": "spec-kit-core",
|
||||||
"repository": "https://github.com/github/spec-kit",
|
"repository": "https://github.com/github/spec-kit",
|
||||||
"tags": ["generic"]
|
"tags": ["generic"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "specify-cli"
|
name = "specify-cli"
|
||||||
version = "0.9.6.dev0"
|
version = "0.9.5"
|
||||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ from ._version import (
|
|||||||
)
|
)
|
||||||
from ._agent_config import (
|
from ._agent_config import (
|
||||||
AGENT_CONFIG as AGENT_CONFIG,
|
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,
|
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
|
||||||
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
|
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,4 +17,29 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
|
|||||||
|
|
||||||
DEFAULT_INIT_INTEGRATION = "copilot"
|
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"}
|
SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,6 +14,8 @@ from rich.panel import Panel
|
|||||||
|
|
||||||
from .._agent_config import (
|
from .._agent_config import (
|
||||||
AGENT_CONFIG,
|
AGENT_CONFIG,
|
||||||
|
AI_ASSISTANT_ALIASES,
|
||||||
|
AI_ASSISTANT_HELP,
|
||||||
DEFAULT_INIT_INTEGRATION,
|
DEFAULT_INIT_INTEGRATION,
|
||||||
SCRIPT_TYPE_CHOICES,
|
SCRIPT_TYPE_CHOICES,
|
||||||
)
|
)
|
||||||
@@ -25,6 +28,31 @@ from .._assets import (
|
|||||||
from .._console import StepTracker, console, select_with_arrows, show_banner
|
from .._console import StepTracker, console, select_with_arrows, show_banner
|
||||||
from .._utils import check_tool, init_git_repo, is_git_repo
|
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:
|
def _stdin_is_interactive() -> bool:
|
||||||
return sys.stdin.isatty()
|
return sys.stdin.isatty()
|
||||||
@@ -69,6 +97,8 @@ def register(app: typer.Typer) -> None:
|
|||||||
@app.command()
|
@app.command()
|
||||||
def init(
|
def init(
|
||||||
project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"),
|
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"),
|
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"),
|
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"),
|
no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"),
|
||||||
@@ -77,10 +107,11 @@ 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),
|
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),
|
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),
|
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),
|
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)"),
|
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)"),
|
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="AI coding agent integration to use (e.g. --integration copilot). See 'specify check' for available integrations."),
|
integration: str = typer.Option(None, "--integration", help="Use the new integration system (e.g. --integration copilot). Mutually exclusive with --ai."),
|
||||||
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
integration_options: str = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -132,6 +163,27 @@ def register(app: typer.Typer) -> None:
|
|||||||
from ..integration_runtime import with_integration_setting as _with_integration_setting
|
from ..integration_runtime import with_integration_setting as _with_integration_setting
|
||||||
|
|
||||||
show_banner()
|
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
|
from ..integrations import INTEGRATION_REGISTRY, get_integration
|
||||||
if integration:
|
if integration:
|
||||||
@@ -141,6 +193,35 @@ def register(app: typer.Typer) -> None:
|
|||||||
available = ", ".join(sorted(INTEGRATION_REGISTRY))
|
available = ", ".join(sorted(INTEGRATION_REGISTRY))
|
||||||
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
||||||
raise typer.Exit(1)
|
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:
|
if no_git:
|
||||||
console.print(
|
console.print(
|
||||||
@@ -161,6 +242,11 @@ 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")
|
console.print("[red]Error:[/red] Must specify either a project name, use '.' for current directory, or use --here flag")
|
||||||
raise typer.Exit(1)
|
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"}
|
BRANCH_NUMBERING_CHOICES = {"sequential", "timestamp"}
|
||||||
if branch_numbering and branch_numbering not in BRANCH_NUMBERING_CHOICES:
|
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))}")
|
console.print(f"[red]Error:[/red] Invalid --branch-numbering value '{branch_numbering}'. Choose from: {', '.join(sorted(BRANCH_NUMBERING_CHOICES))}")
|
||||||
@@ -209,11 +295,11 @@ def register(app: typer.Typer) -> None:
|
|||||||
console.print(error_panel)
|
console.print(error_panel)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if integration:
|
if ai_assistant:
|
||||||
if integration not in AGENT_CONFIG:
|
if ai_assistant not in AGENT_CONFIG:
|
||||||
console.print(f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
console.print(f"[red]Error:[/red] Invalid AI assistant '{ai_assistant}'. Choose from: {', '.join(AGENT_CONFIG.keys())}")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
selected_ai = integration
|
selected_ai = ai_assistant
|
||||||
elif not _stdin_is_interactive():
|
elif not _stdin_is_interactive():
|
||||||
console.print(
|
console.print(
|
||||||
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
|
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
|
||||||
@@ -228,16 +314,17 @@ def register(app: typer.Typer) -> None:
|
|||||||
DEFAULT_INIT_INTEGRATION,
|
DEFAULT_INIT_INTEGRATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not integration:
|
if not ai_assistant:
|
||||||
resolved_integration = get_integration(selected_ai)
|
resolved_integration = get_integration(selected_ai)
|
||||||
if not resolved_integration:
|
if not resolved_integration:
|
||||||
console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'")
|
console.print(f"[red]Error:[/red] Unknown agent '{selected_ai}'")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
if selected_ai == "generic" and not integration_options:
|
if selected_ai == "generic" and not integration_options:
|
||||||
console.print("[red]Error:[/red] --integration generic requires --integration-options with --commands-dir")
|
if not ai_commands_dir:
|
||||||
console.print('[dim]Example: specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/"[/dim]')
|
console.print("[red]Error:[/red] --ai-commands-dir is required when using --ai generic or --integration generic")
|
||||||
raise typer.Exit(1)
|
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()
|
current_dir = Path.cwd()
|
||||||
|
|
||||||
@@ -327,6 +414,10 @@ def register(app: typer.Typer) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
integration_parsed_options: dict[str, Any] = {}
|
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:
|
if integration_options:
|
||||||
extra = _parse_integration_options(resolved_integration, integration_options)
|
extra = _parse_integration_options(resolved_integration, integration_options)
|
||||||
if extra:
|
if extra:
|
||||||
@@ -584,7 +675,7 @@ def register(app: typer.Typer) -> None:
|
|||||||
|
|
||||||
agent_config = AGENT_CONFIG.get(selected_ai)
|
agent_config = AGENT_CONFIG.get(selected_ai)
|
||||||
if agent_config:
|
if agent_config:
|
||||||
agent_folder = agent_config["folder"] or integration_parsed_options.get("commands_dir")
|
agent_folder = ai_commands_dir if selected_ai == "generic" else agent_config["folder"]
|
||||||
if agent_folder:
|
if agent_folder:
|
||||||
security_notice = Panel(
|
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"
|
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
||||||
@@ -596,6 +687,16 @@ def register(app: typer.Typer) -> None:
|
|||||||
console.print()
|
console.print()
|
||||||
console.print(security_notice)
|
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:
|
if git_default_notice:
|
||||||
default_change_notice = Panel(
|
default_change_notice = Panel(
|
||||||
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
|
"The git extension is currently enabled by default during [bold]specify init[/bold].\n"
|
||||||
@@ -619,24 +720,24 @@ def register(app: typer.Typer) -> None:
|
|||||||
from ..integrations.base import SkillsIntegration as _SkillsInt
|
from ..integrations.base import SkillsIntegration as _SkillsInt
|
||||||
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
|
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
|
||||||
|
|
||||||
codex_skill_mode = selected_ai == "codex" and _is_skills_integration
|
codex_skill_mode = selected_ai == "codex" and (ai_skills or _is_skills_integration)
|
||||||
claude_skill_mode = selected_ai == "claude" and _is_skills_integration
|
claude_skill_mode = selected_ai == "claude" and (ai_skills or _is_skills_integration)
|
||||||
kimi_skill_mode = selected_ai == "kimi"
|
kimi_skill_mode = selected_ai == "kimi"
|
||||||
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
agy_skill_mode = selected_ai == "agy" and _is_skills_integration
|
||||||
trae_skill_mode = selected_ai == "trae"
|
trae_skill_mode = selected_ai == "trae"
|
||||||
cursor_agent_skill_mode = selected_ai == "cursor-agent" and _is_skills_integration
|
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
|
||||||
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
|
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
|
||||||
devin_skill_mode = selected_ai == "devin"
|
devin_skill_mode = selected_ai == "devin"
|
||||||
cline_skill_mode = selected_ai == "cline"
|
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
|
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:
|
if codex_skill_mode and not ai_skills:
|
||||||
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
|
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
|
step_num += 1
|
||||||
if claude_skill_mode:
|
if claude_skill_mode and not ai_skills:
|
||||||
steps_lines.append(f"{step_num}. Start Claude in this project directory; spec-kit skills were installed to [cyan].claude/skills[/cyan]")
|
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
|
step_num += 1
|
||||||
if cursor_agent_skill_mode:
|
if cursor_agent_skill_mode and not ai_skills:
|
||||||
steps_lines.append(f"{step_num}. Start Cursor Agent in this project directory; spec-kit skills were installed to [cyan].cursor/skills[/cyan]")
|
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
|
step_num += 1
|
||||||
if devin_skill_mode:
|
if devin_skill_mode:
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset({
|
|||||||
})
|
})
|
||||||
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
|
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"
|
REINSTALL_COMMAND = "uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git"
|
||||||
|
|
||||||
|
|
||||||
@@ -91,21 +89,19 @@ class CompatibilityError(ExtensionError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def normalize_priority(value: Any, default: int = DEFAULT_HOOK_PRIORITY) -> int:
|
def normalize_priority(value: Any, default: int = 10) -> int:
|
||||||
"""Normalize a stored priority value for sorting and display.
|
"""Normalize a stored priority value for sorting and display.
|
||||||
|
|
||||||
Corrupted registry data may contain missing, non-numeric, non-positive, or
|
Corrupted registry data may contain missing, non-numeric, or non-positive
|
||||||
boolean values. In those cases, fall back to the default priority.
|
values. In those cases, fall back to the default priority.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: Priority value to normalize (may be int, str, None, etc.)
|
value: Priority value to normalize (may be int, str, None, etc.)
|
||||||
default: Default priority to use for invalid values
|
default: Default priority to use for invalid values (default: 10)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Normalized priority as positive integer (>= 1)
|
Normalized priority as positive integer (>= 1)
|
||||||
"""
|
"""
|
||||||
if isinstance(value, bool):
|
|
||||||
return default
|
|
||||||
try:
|
try:
|
||||||
priority = int(value)
|
priority = int(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -113,15 +109,6 @@ def normalize_priority(value: Any, default: int = DEFAULT_HOOK_PRIORITY) -> int:
|
|||||||
return priority if priority >= 1 else default
|
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
|
@dataclass
|
||||||
class CatalogEntry(BaseCatalogEntry):
|
class CatalogEntry(BaseCatalogEntry):
|
||||||
"""Represents a single catalog entry in the catalog stack."""
|
"""Represents a single catalog entry in the catalog stack."""
|
||||||
@@ -228,36 +215,17 @@ class ExtensionManifest:
|
|||||||
"Extension must provide at least one command or hook"
|
"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:
|
if hooks:
|
||||||
for hook_name, hook_config in hooks.items():
|
for hook_name, hook_config in hooks.items():
|
||||||
if isinstance(hook_config, list) and not hook_config:
|
if not isinstance(hook_config, dict):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Invalid hook '{hook_name}': list must contain at least one entry"
|
f"Invalid hook '{hook_name}': expected a mapping"
|
||||||
|
)
|
||||||
|
if not hook_config.get("command"):
|
||||||
|
raise ValidationError(
|
||||||
|
f"Hook '{hook_name}' missing required 'command' field"
|
||||||
)
|
)
|
||||||
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.
|
# Validate commands; track renames so hook references can be rewritten.
|
||||||
rename_map: Dict[str, str] = {}
|
rename_map: Dict[str, str] = {}
|
||||||
@@ -307,30 +275,28 @@ class ExtensionManifest:
|
|||||||
# an alias-form ref (ext.cmd → speckit.ext.cmd). Always emit a warning when
|
# 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.
|
# the reference is changed so extension authors know to update the manifest.
|
||||||
for hook_name, hook_data in self.data.get("hooks", {}).items():
|
for hook_name, hook_data in self.data.get("hooks", {}).items():
|
||||||
for entry in coerce_hook_entries(hook_data):
|
if not isinstance(hook_data, dict):
|
||||||
if not isinstance(entry, dict):
|
raise ValidationError(
|
||||||
raise ValidationError(
|
f"Hook '{hook_name}' must be a mapping, got {type(hook_data).__name__}"
|
||||||
f"Hook '{hook_name}' must be a mapping or list of mappings, "
|
)
|
||||||
f"got {type(entry).__name__}"
|
command_ref = hook_data.get("command")
|
||||||
)
|
if not isinstance(command_ref, str):
|
||||||
command_ref = entry.get("command")
|
continue
|
||||||
if not isinstance(command_ref, str):
|
# Step 1: apply any rename from the auto-correction pass.
|
||||||
continue
|
after_rename = rename_map.get(command_ref, command_ref)
|
||||||
# Step 1: apply any rename from the auto-correction pass.
|
# Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'.
|
||||||
after_rename = rename_map.get(command_ref, command_ref)
|
parts = after_rename.split(".")
|
||||||
# Step 2: lift alias-form '{ext_id}.cmd' to canonical 'speckit.{ext_id}.cmd'.
|
if len(parts) == 2 and parts[0] == ext["id"]:
|
||||||
parts = after_rename.split(".")
|
final_ref = f"speckit.{ext['id']}.{parts[1]}"
|
||||||
if len(parts) == 2 and parts[0] == ext["id"]:
|
else:
|
||||||
final_ref = f"speckit.{ext['id']}.{parts[1]}"
|
final_ref = after_rename
|
||||||
else:
|
if final_ref != command_ref:
|
||||||
final_ref = after_rename
|
hook_data["command"] = final_ref
|
||||||
if final_ref != command_ref:
|
self.warnings.append(
|
||||||
entry["command"] = final_ref
|
f"Hook '{hook_name}' referenced command '{command_ref}'; "
|
||||||
self.warnings.append(
|
f"updated to canonical form '{final_ref}'. "
|
||||||
f"Hook '{hook_name}' referenced command '{command_ref}'; "
|
f"The extension author should update the manifest."
|
||||||
f"updated to canonical form '{final_ref}'. "
|
)
|
||||||
f"The extension author should update the manifest."
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
|
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
|
||||||
@@ -923,7 +889,7 @@ class ExtensionManager:
|
|||||||
|
|
||||||
For every command in the extension manifest, creates a SKILL.md
|
For every command in the extension manifest, creates a SKILL.md
|
||||||
file in the agent's skills directory following the agentskills.io
|
file in the agent's skills directory following the agentskills.io
|
||||||
specification. This is only done when skills mode was used
|
specification. This is only done when ``--ai-skills`` was used
|
||||||
during project initialisation.
|
during project initialisation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1329,7 +1295,7 @@ class ExtensionManager:
|
|||||||
create_missing_active_skills_dir=True,
|
create_missing_active_skills_dir=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-register extension commands as agent skills when skills mode
|
# Auto-register extension commands as agent skills when --ai-skills
|
||||||
# was used during project initialisation (feature parity).
|
# was used during project initialisation (feature parity).
|
||||||
registered_skills = self._register_extension_skills(
|
registered_skills = self._register_extension_skills(
|
||||||
manifest, dest_dir, link_outputs=link_commands
|
manifest, dest_dir, link_outputs=link_commands
|
||||||
@@ -2768,6 +2734,9 @@ class HookExecutor:
|
|||||||
# Always ensure the extension is in the installed list
|
# Always ensure the extension is in the installed list
|
||||||
self.register_extension(manifest.id)
|
self.register_extension(manifest.id)
|
||||||
|
|
||||||
|
if not hasattr(manifest, "hooks") or not manifest.hooks:
|
||||||
|
return
|
||||||
|
|
||||||
config = self.get_project_config()
|
config = self.get_project_config()
|
||||||
|
|
||||||
# Ensure config is a dict (defensive)
|
# Ensure config is a dict (defensive)
|
||||||
@@ -2793,68 +2762,39 @@ class HookExecutor:
|
|||||||
config["hooks"][h_name] = sanitized_h_list
|
config["hooks"][h_name] = sanitized_h_list
|
||||||
changed = True
|
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
|
# Register each hook
|
||||||
for hook_name, hook_config in manifest.hooks.items():
|
for hook_name, hook_config in manifest.hooks.items():
|
||||||
if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list):
|
if hook_name not in config["hooks"] or not isinstance(config["hooks"][hook_name], list):
|
||||||
config["hooks"][hook_name] = []
|
config["hooks"][hook_name] = []
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
# Key by command to dedup within the manifest. Deleting before
|
# Add hook entry
|
||||||
# re-insert moves a duplicate to the end so "last wins" also breaks ties.
|
hook_entry = {
|
||||||
new_entries: Dict[str, Dict[str, Any]] = {}
|
"extension": manifest.id,
|
||||||
for entry in coerce_hook_entries(hook_config):
|
"command": hook_config.get("command"),
|
||||||
if not isinstance(entry, dict):
|
"enabled": True,
|
||||||
continue
|
"optional": hook_config.get("optional", True),
|
||||||
command = entry.get("command")
|
"prompt": hook_config.get(
|
||||||
if not command:
|
"prompt", f"Execute {hook_config.get('command')}?"
|
||||||
continue
|
),
|
||||||
if command in new_entries:
|
"description": hook_config.get("description", ""),
|
||||||
del new_entries[command]
|
"condition": hook_config.get("condition"),
|
||||||
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"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Purge then re-add all of this extension's entries for the event.
|
# Deduplicate: remove all existing entries for this extension on this
|
||||||
# A reinstall with a changed shape (single<->list or a shorter list)
|
# hook event, then append the single canonical entry. This prevents
|
||||||
# then leaves no orphaned entries behind.
|
# multiple hooks firing when hand-edited or older versions leave
|
||||||
|
# duplicate entries behind. (Feedback from review)
|
||||||
original_list = config["hooks"][hook_name]
|
original_list = config["hooks"][hook_name]
|
||||||
deduped = [
|
deduped = [
|
||||||
h for h in original_list
|
h for h in original_list
|
||||||
if not (isinstance(h, dict) and h.get("extension") == manifest.id)
|
if not (isinstance(h, dict) and h.get("extension") == manifest.id)
|
||||||
]
|
]
|
||||||
deduped.extend(new_entries.values())
|
deduped.append(hook_entry)
|
||||||
if deduped != original_list:
|
if deduped != original_list:
|
||||||
config["hooks"][hook_name] = deduped
|
config["hooks"][hook_name] = deduped
|
||||||
changed = True
|
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:
|
if changed:
|
||||||
self.save_project_config(config)
|
self.save_project_config(config)
|
||||||
|
|
||||||
@@ -2898,26 +2838,19 @@ class HookExecutor:
|
|||||||
self.save_project_config(config)
|
self.save_project_config(config)
|
||||||
|
|
||||||
def get_hooks_for_event(self, event_name: str) -> List[Dict[str, Any]]:
|
def get_hooks_for_event(self, event_name: str) -> List[Dict[str, Any]]:
|
||||||
"""Get all enabled hooks for a specific event, sorted by priority ascending.
|
"""Get all registered hooks for a specific event.
|
||||||
|
|
||||||
Lower ``priority`` runs first. Ties keep insertion order via a stable
|
|
||||||
sort. Missing or corrupted on-disk priorities fall back to the default.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event_name: Name of the event (e.g., 'after_tasks')
|
event_name: Name of the event (e.g., 'after_tasks')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of enabled hook configurations sorted by priority.
|
List of hook configurations
|
||||||
"""
|
"""
|
||||||
config = self.get_project_config()
|
config = self.get_project_config()
|
||||||
hooks = config.get("hooks", {}).get(event_name, [])
|
hooks = config.get("hooks", {}).get(event_name, [])
|
||||||
|
|
||||||
# Filter to enabled hooks only
|
# Filter to enabled hooks only
|
||||||
enabled = [h for h in hooks if h.get("enabled", True)]
|
return [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:
|
def should_execute_hook(self, hook: Dict[str, Any]) -> bool:
|
||||||
"""Determine if a hook should be executed based on its condition.
|
"""Determine if a hook should be executed based on its condition.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class CursorAgentIntegration(SkillsIntegration):
|
|||||||
"folder": ".cursor/",
|
"folder": ".cursor/",
|
||||||
"commands_subdir": "skills",
|
"commands_subdir": "skills",
|
||||||
"install_url": "https://docs.cursor.com/en/cli/overview",
|
"install_url": "https://docs.cursor.com/en/cli/overview",
|
||||||
# IDE-first integration: ``specify init --integration cursor-agent`` must
|
# IDE-first integration: ``specify init --ai cursor-agent`` must
|
||||||
# work without the ``cursor-agent`` CLI installed (the IDE flow
|
# work without the ``cursor-agent`` CLI installed (the IDE flow
|
||||||
# uses skills directly). Workflow dispatch additionally requires
|
# uses skills directly). Workflow dispatch additionally requires
|
||||||
# the CLI on PATH, but that's enforced at dispatch time via
|
# 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::
|
Usage::
|
||||||
|
|
||||||
specify init my-project --integration hermes
|
specify init my-project --integration hermes
|
||||||
specify init --here --integration hermes
|
specify init --here --ai hermes
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|||||||
@@ -1219,7 +1219,7 @@ class PresetManager:
|
|||||||
directory. If so, the skill is overwritten with content derived
|
directory. If so, the skill is overwritten with content derived
|
||||||
from the preset's command file. This ensures that presets that
|
from the preset's command file. This ensures that presets that
|
||||||
override commands also propagate to the agentskills.io skill
|
override commands also propagate to the agentskills.io skill
|
||||||
layer when skills mode was used during project initialisation.
|
layer when ``--ai-skills`` was used during project initialisation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
manifest: Preset manifest.
|
manifest: Preset manifest.
|
||||||
@@ -1559,7 +1559,7 @@ class PresetManager:
|
|||||||
"registered_commands": registered_commands,
|
"registered_commands": registered_commands,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Update corresponding skills when skills mode was previously used
|
# Update corresponding skills when --ai-skills was previously used
|
||||||
# and persist that result as well.
|
# and persist that result as well.
|
||||||
registered_skills = self._register_skills(manifest, dest_dir)
|
registered_skills = self._register_skills(manifest, dest_dir)
|
||||||
self.registry.update(manifest.id, {
|
self.registry.update(manifest.id, {
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ class TestCliDiagnosticFormatting:
|
|||||||
|
|
||||||
|
|
||||||
class TestInitIntegrationFlag:
|
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):
|
def test_unknown_integration_rejected(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
@@ -121,7 +131,7 @@ class TestInitIntegrationFlag:
|
|||||||
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
||||||
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
assert data["integration"] == specify_cli.DEFAULT_INIT_INTEGRATION
|
||||||
|
|
||||||
def test_integration_copilot_auto_promotes(self, tmp_path):
|
def test_ai_copilot_auto_promotes(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
project = tmp_path / "promote-test"
|
project = tmp_path / "promote-test"
|
||||||
@@ -131,13 +141,66 @@ class TestInitIntegrationFlag:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
"init", "--here", "--ai", "copilot", "--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
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(
|
def test_init_optional_preset_failure_reports_target_and_continues(
|
||||||
self, tmp_path, monkeypatch
|
self, tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
@@ -174,7 +237,7 @@ class TestInitIntegrationFlag:
|
|||||||
assert "Continuing without the optional preset" in normalized
|
assert "Continuing without the optional preset" in normalized
|
||||||
assert "Project ready" in normalized
|
assert "Project ready" in normalized
|
||||||
|
|
||||||
def test_integration_claude_here_preserves_preexisting_commands(self, tmp_path):
|
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
@@ -192,7 +255,7 @@ class TestInitIntegrationFlag:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--force", "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools",
|
"init", "--here", "--force", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
@@ -737,7 +800,7 @@ class TestGitExtensionAutoInstall:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -775,7 +838,7 @@ class TestGitExtensionAutoInstall:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||||
"--no-git", "--ignore-agent-tools",
|
"--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -799,7 +862,7 @@ class TestGitExtensionAutoInstall:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||||
"--no-git", "--ignore-agent-tools",
|
"--no-git", "--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -826,7 +889,7 @@ class TestGitExtensionAutoInstall:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -852,7 +915,7 @@ class TestGitExtensionAutoInstall:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "claude", "--script", "sh",
|
"init", "--here", "--ai", "claude", "--script", "sh",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -29,19 +29,19 @@ class TestAgyIntegration(SkillsIntegrationTests):
|
|||||||
assert i.config["install_url"] == "https://antigravity.google/"
|
assert i.config["install_url"] == "https://antigravity.google/"
|
||||||
|
|
||||||
|
|
||||||
class TestAgyInitFlow:
|
class TestAgyAutoPromote:
|
||||||
"""--integration agy creates expected files."""
|
"""--ai agy auto-promotes to integration path."""
|
||||||
|
|
||||||
def test_integration_agy_creates_skills(self, tmp_path):
|
def test_ai_agy_without_ai_skills_auto_promotes(self, tmp_path):
|
||||||
"""--integration agy should create skills directory."""
|
"""--ai agy should work the same as --integration agy."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "test-proj"
|
target = tmp_path / "test-proj"
|
||||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai agy failed: {result.output}"
|
||||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
|
|
||||||
def test_agy_setup_warning(self, tmp_path):
|
def test_agy_setup_warning(self, tmp_path):
|
||||||
@@ -52,7 +52,7 @@ class TestAgyInitFlow:
|
|||||||
# Click >= 8.2 separates stdout and stderr natively
|
# Click >= 8.2 separates stdout and stderr natively
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "test-proj2"
|
target = tmp_path / "test-proj2"
|
||||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
result = runner.invoke(app, ["init", str(target), "--ai", "agy", "--no-git", "--script", "sh", "--ignore-agent-tools"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
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 "<!-- SPECKIT END -->" not in remaining
|
||||||
assert "# My Rules" in remaining
|
assert "# My Rules" in remaining
|
||||||
|
|
||||||
# -- CLI integration flag -------------------------------------------------
|
# -- CLI auto-promote -------------------------------------------------
|
||||||
|
|
||||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
def test_ai_flag_auto_promotes(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
@@ -192,15 +192,15 @@ class MarkdownIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||||
i = get_integration(self.KEY)
|
i = get_integration(self.KEY)
|
||||||
cmd_dir = i.commands_dest(project)
|
cmd_dir = i.commands_dest(project)
|
||||||
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
|
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
|
||||||
|
|
||||||
def test_integration_flag_creates_files(self, tmp_path):
|
def test_integration_flag_creates_files(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|||||||
@@ -312,9 +312,9 @@ class SkillsIntegrationTests:
|
|||||||
assert "<!-- SPECKIT END -->" not in remaining
|
assert "<!-- SPECKIT END -->" not in remaining
|
||||||
assert "# My Rules" in remaining
|
assert "# My Rules" in remaining
|
||||||
|
|
||||||
# -- CLI integration flag -------------------------------------------------
|
# -- CLI auto-promote -------------------------------------------------
|
||||||
|
|
||||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
def test_ai_flag_auto_promotes(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
@@ -325,15 +325,15 @@ class SkillsIntegrationTests:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", self.KEY, "--script", "sh", "--no-git",
|
"init", "--here", "--ai", self.KEY, "--script", "sh", "--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||||
i = get_integration(self.KEY)
|
i = get_integration(self.KEY)
|
||||||
skills_dir = i.skills_dest(project)
|
skills_dir = i.skills_dest(project)
|
||||||
assert skills_dir.is_dir(), f"--integration {self.KEY} did not create skills directory"
|
assert skills_dir.is_dir(), f"--ai {self.KEY} did not create skills directory"
|
||||||
|
|
||||||
def test_integration_flag_creates_files(self, tmp_path):
|
def test_integration_flag_creates_files(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|||||||
@@ -388,9 +388,9 @@ class TomlIntegrationTests:
|
|||||||
assert "<!-- SPECKIT END -->" not in remaining
|
assert "<!-- SPECKIT END -->" not in remaining
|
||||||
assert "# My Rules" in remaining
|
assert "# My Rules" in remaining
|
||||||
|
|
||||||
# -- CLI integration flag -------------------------------------------------
|
# -- CLI auto-promote -------------------------------------------------
|
||||||
|
|
||||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
def test_ai_flag_auto_promotes(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
@@ -405,7 +405,7 @@ class TomlIntegrationTests:
|
|||||||
[
|
[
|
||||||
"init",
|
"init",
|
||||||
"--here",
|
"--here",
|
||||||
"--integration",
|
"--ai",
|
||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
@@ -416,10 +416,10 @@ class TomlIntegrationTests:
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||||
i = get_integration(self.KEY)
|
i = get_integration(self.KEY)
|
||||||
cmd_dir = i.commands_dest(project)
|
cmd_dir = i.commands_dest(project)
|
||||||
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
|
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
|
||||||
|
|
||||||
def test_integration_flag_creates_files(self, tmp_path):
|
def test_integration_flag_creates_files(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|||||||
@@ -267,9 +267,9 @@ class YamlIntegrationTests:
|
|||||||
assert "<!-- SPECKIT END -->" not in remaining
|
assert "<!-- SPECKIT END -->" not in remaining
|
||||||
assert "# My Rules" in remaining
|
assert "# My Rules" in remaining
|
||||||
|
|
||||||
# -- CLI integration flag -------------------------------------------------
|
# -- CLI auto-promote -------------------------------------------------
|
||||||
|
|
||||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
def test_ai_flag_auto_promotes(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ class YamlIntegrationTests:
|
|||||||
[
|
[
|
||||||
"init",
|
"init",
|
||||||
"--here",
|
"--here",
|
||||||
"--integration",
|
"--ai",
|
||||||
self.KEY,
|
self.KEY,
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
@@ -295,10 +295,10 @@ class YamlIntegrationTests:
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai {self.KEY} failed: {result.output}"
|
||||||
i = get_integration(self.KEY)
|
i = get_integration(self.KEY)
|
||||||
cmd_dir = i.commands_dest(project)
|
cmd_dir = i.commands_dest(project)
|
||||||
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
|
assert cmd_dir.is_dir(), f"--ai {self.KEY} did not create commands directory"
|
||||||
|
|
||||||
def test_integration_flag_creates_files(self, tmp_path):
|
def test_integration_flag_creates_files(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class TestClaudeIntegration:
|
|||||||
assert b"<!-- SPECKIT" not in remaining
|
assert b"<!-- SPECKIT" not in remaining
|
||||||
assert b"# CLAUDE.md" in remaining
|
assert b"# CLAUDE.md" in remaining
|
||||||
|
|
||||||
def test_integration_flag_creates_skill_files_cli(self, tmp_path):
|
def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path):
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ class TestClaudeIntegration:
|
|||||||
[
|
[
|
||||||
"init",
|
"init",
|
||||||
"--here",
|
"--here",
|
||||||
"--integration",
|
"--ai",
|
||||||
"claude",
|
"claude",
|
||||||
"--script",
|
"--script",
|
||||||
"sh",
|
"sh",
|
||||||
@@ -234,7 +234,7 @@ class TestClaudeIntegration:
|
|||||||
assert init_options["integration"] == "claude"
|
assert init_options["integration"] == "claude"
|
||||||
|
|
||||||
def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
|
def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
|
||||||
"""Claude init should succeed even without install_skills."""
|
"""Claude init should succeed even without install_ai_skills."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ class TestClaudeIntegration:
|
|||||||
|
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["init", str(target), "--integration", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
|
["init", str(target), "--ai", "claude", "--script", "sh", "--no-git", "--ignore-agent-tools"],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|||||||
@@ -14,19 +14,19 @@ class TestCodexIntegration(SkillsIntegrationTests):
|
|||||||
CONTEXT_FILE = "AGENTS.md"
|
CONTEXT_FILE = "AGENTS.md"
|
||||||
|
|
||||||
|
|
||||||
class TestCodexInitFlow:
|
class TestCodexAutoPromote:
|
||||||
"""--integration codex creates expected files."""
|
"""--ai codex auto-promotes to integration path."""
|
||||||
|
|
||||||
def test_integration_codex_creates_skills(self, tmp_path):
|
def test_ai_codex_without_ai_skills_auto_promotes(self, tmp_path):
|
||||||
"""--integration codex should create skills in .agents/skills."""
|
"""--ai codex should work the same as --integration codex."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "test-proj"
|
target = tmp_path / "test-proj"
|
||||||
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
result = runner.invoke(app, ["init", str(target), "--ai", "codex", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai codex failed: {result.output}"
|
||||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -92,19 +92,19 @@ class TestCursorMdcFrontmatter:
|
|||||||
assert not ctx_path.exists()
|
assert not ctx_path.exists()
|
||||||
|
|
||||||
|
|
||||||
class TestCursorAgentInitFlow:
|
class TestCursorAgentAutoPromote:
|
||||||
"""--integration cursor-agent creates expected files."""
|
"""--ai cursor-agent auto-promotes to integration path."""
|
||||||
|
|
||||||
def test_integration_cursor_agent_creates_skills(self, tmp_path):
|
def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path):
|
||||||
"""--integration cursor-agent should create skills in .cursor/skills."""
|
"""--ai cursor-agent should work the same as --integration cursor-agent."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
target = tmp_path / "test-proj"
|
target = tmp_path / "test-proj"
|
||||||
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
result = runner.invoke(app, ["init", str(target), "--ai", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}"
|
||||||
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
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):
|
def test_requires_cli_is_false_for_ide_first_flow(self):
|
||||||
"""``requires_cli`` must stay False so the IDE-only flow keeps working.
|
"""``requires_cli`` must stay False so the IDE-only flow keeps working.
|
||||||
|
|
||||||
``specify init --integration cursor-agent`` (without ``--ignore-agent-tools``)
|
``specify init --ai cursor-agent`` (without ``--ignore-agent-tools``)
|
||||||
treats ``requires_cli=True`` as a hard precheck and fails when the
|
treats ``requires_cli=True`` as a hard precheck and fails when the
|
||||||
``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE
|
``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE
|
||||||
/ skills flow can run without it. Workflow dispatch support is
|
/ 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"]
|
assert args == ["devin", "-p", "hi", "--model", "claude-sonnet-4"]
|
||||||
|
|
||||||
|
|
||||||
class TestDevinInitFlow:
|
class TestDevinAutoPromote:
|
||||||
"""--integration devin creates expected files."""
|
"""--ai devin auto-promotes to integration path."""
|
||||||
|
|
||||||
def test_integration_devin_creates_skills(self, tmp_path):
|
def test_ai_devin_without_ai_skills_auto_promotes(self, tmp_path):
|
||||||
"""--integration devin should create skills directory."""
|
"""--ai devin should work the same as --integration devin."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
@@ -68,8 +68,8 @@ class TestDevinInitFlow:
|
|||||||
target = tmp_path / "test-proj"
|
target = tmp_path / "test-proj"
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
app,
|
app,
|
||||||
["init", str(target), "--integration", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"],
|
["init", str(target), "--ai", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai devin failed: {result.output}"
|
||||||
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
@@ -245,7 +245,7 @@ class TestGenericIntegration:
|
|||||||
# -- CLI --------------------------------------------------------------
|
# -- CLI --------------------------------------------------------------
|
||||||
|
|
||||||
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
|
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
|
||||||
"""--integration generic without --integration-options should fail."""
|
"""--integration generic without --ai-commands-dir should fail."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
@@ -253,7 +253,8 @@ class TestGenericIntegration:
|
|||||||
"init", str(tmp_path / "test-generic"), "--integration", "generic",
|
"init", str(tmp_path / "test-generic"), "--integration", "generic",
|
||||||
"--script", "sh", "--no-git",
|
"--script", "sh", "--no-git",
|
||||||
])
|
])
|
||||||
# Generic requires --commands-dir via --integration-options
|
# Generic requires --commands-dir / --ai-commands-dir
|
||||||
|
# The integration path validates via setup()
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
|
|
||||||
def test_init_options_includes_context_file(self, tmp_path):
|
def test_init_options_includes_context_file(self, tmp_path):
|
||||||
@@ -269,7 +270,7 @@ class TestGenericIntegration:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "generic",
|
"init", "--here", "--integration", "generic",
|
||||||
"--integration-options=--commands-dir .myagent/commands",
|
"--ai-commands-dir", ".myagent/commands",
|
||||||
"--script", "sh", "--no-git",
|
"--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -280,7 +281,7 @@ class TestGenericIntegration:
|
|||||||
assert ext_cfg.get("context_file") == "AGENTS.md"
|
assert ext_cfg.get("context_file") == "AGENTS.md"
|
||||||
|
|
||||||
def test_complete_file_inventory_sh(self, tmp_path):
|
def test_complete_file_inventory_sh(self, tmp_path):
|
||||||
"""Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script sh."""
|
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script sh."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
@@ -291,7 +292,7 @@ class TestGenericIntegration:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "generic",
|
"init", "--here", "--integration", "generic",
|
||||||
"--integration-options=--commands-dir .myagent/commands",
|
"--ai-commands-dir", ".myagent/commands",
|
||||||
"--script", "sh", "--no-git",
|
"--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
@@ -344,7 +345,7 @@ class TestGenericIntegration:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_complete_file_inventory_ps(self, tmp_path):
|
def test_complete_file_inventory_ps(self, tmp_path):
|
||||||
"""Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script ps."""
|
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script ps."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
@@ -355,7 +356,7 @@ class TestGenericIntegration:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
result = CliRunner().invoke(app, [
|
result = CliRunner().invoke(app, [
|
||||||
"init", "--here", "--integration", "generic",
|
"init", "--here", "--integration", "generic",
|
||||||
"--integration-options=--commands-dir .myagent/commands",
|
"--ai-commands-dir", ".myagent/commands",
|
||||||
"--script", "ps", "--no-git",
|
"--script", "ps", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -326,11 +326,12 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestHermesInitFlow:
|
class TestHermesAutoPromote:
|
||||||
"""--integration hermes creates expected files."""
|
"""--ai hermes auto-promotes to integration path."""
|
||||||
|
|
||||||
def test_integration_hermes_creates_global_skills(self, tmp_path, monkeypatch):
|
def test_ai_hermes_without_ai_skills_auto_promotes(self, tmp_path, monkeypatch):
|
||||||
"""--integration hermes should create global skills and a local marker."""
|
"""--ai hermes should work the same as --integration hermes,
|
||||||
|
creating global skills and a local marker."""
|
||||||
home = _fake_home(tmp_path)
|
home = _fake_home(tmp_path)
|
||||||
monkeypatch.setattr(Path, "home", lambda: home)
|
monkeypatch.setattr(Path, "home", lambda: home)
|
||||||
|
|
||||||
@@ -341,13 +342,13 @@ class TestHermesInitFlow:
|
|||||||
target = tmp_path / "test-proj"
|
target = tmp_path / "test-proj"
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", str(target),
|
"init", str(target),
|
||||||
"--integration", "hermes",
|
"--ai", "hermes",
|
||||||
"--no-git",
|
"--no-git",
|
||||||
"--ignore-agent-tools",
|
"--ignore-agent-tools",
|
||||||
"--script", "sh",
|
"--script", "sh",
|
||||||
])
|
])
|
||||||
|
|
||||||
assert result.exit_code == 0, f"init --integration hermes failed: {result.output}"
|
assert result.exit_code == 0, f"init --ai hermes failed: {result.output}"
|
||||||
# Skills should be in global ~/.hermes/skills/
|
# Skills should be in global ~/.hermes/skills/
|
||||||
assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
# Local marker should exist
|
# Local marker should exist
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class TestKimiNextSteps:
|
|||||||
os.chdir(project)
|
os.chdir(project)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "kimi", "--no-git",
|
"init", "--here", "--ai", "kimi", "--no-git",
|
||||||
"--ignore-agent-tools", "--script", "sh",
|
"--ignore-agent-tools", "--script", "sh",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -123,15 +123,15 @@ class TestKiroCliIntegration(MarkdownIntegrationTests):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestKiroIntegration:
|
class TestKiroAlias:
|
||||||
"""--integration kiro-cli creates expected files."""
|
"""--ai kiro alias normalizes to kiro-cli and auto-promotes."""
|
||||||
|
|
||||||
def test_integration_kiro_cli_creates_files(self, tmp_path):
|
def test_kiro_alias_normalized_to_kiro_cli(self, tmp_path):
|
||||||
"""--integration kiro-cli should create files in .kiro/prompts."""
|
"""--ai kiro should normalize to canonical kiro-cli and auto-promote."""
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
target = tmp_path / "kiro-proj"
|
target = tmp_path / "kiro-alias-proj"
|
||||||
target.mkdir()
|
target.mkdir()
|
||||||
|
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
@@ -139,7 +139,7 @@ class TestKiroIntegration:
|
|||||||
os.chdir(target)
|
os.chdir(target)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, [
|
result = runner.invoke(app, [
|
||||||
"init", "--here", "--integration", "kiro-cli",
|
"init", "--here", "--ai", "kiro",
|
||||||
"--ignore-agent-tools", "--script", "sh", "--no-git",
|
"--ignore-agent-tools", "--script", "sh", "--no-git",
|
||||||
], catch_exceptions=False)
|
], catch_exceptions=False)
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -294,11 +294,11 @@ class TestRovodevIntegration:
|
|||||||
assert init_options.get("ai_skills") is True
|
assert init_options.get("ai_skills") is True
|
||||||
assert init_options.get("script") == "sh"
|
assert init_options.get("script") == "sh"
|
||||||
|
|
||||||
def test_integration_flag_creates_expected_files(self, tmp_path):
|
def test_ai_flag_auto_promotes_to_integration(self, tmp_path):
|
||||||
"""``--integration rovodev`` should create all expected rovodev files."""
|
"""``--ai rovodev`` should reach the same end-state as ``--integration rovodev``."""
|
||||||
project = tmp_path / "rovodev-int"
|
project = tmp_path / "rovodev-ai"
|
||||||
project.mkdir()
|
project.mkdir()
|
||||||
result = _run_init(project, "--integration", "rovodev")
|
result = _run_init(project, "--ai", "rovodev")
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
assert (project / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
assert (project / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||||
assert (project / ".rovodev" / "prompts.yml").exists()
|
assert (project / ".rovodev" / "prompts.yml").exists()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from specify_cli import AGENT_CONFIG
|
from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP
|
||||||
from specify_cli.extensions import CommandRegistrar
|
from specify_cli.extensions import CommandRegistrar
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -39,6 +39,13 @@ class TestAgentConfigConsistency:
|
|||||||
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
|
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
|
||||||
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
|
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):
|
def test_devcontainer_kiro_installer_uses_pinned_checksum(self):
|
||||||
"""Devcontainer installer should always verify Kiro installer via pinned SHA256."""
|
"""Devcontainer installer should always verify Kiro installer via pinned SHA256."""
|
||||||
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(
|
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(
|
||||||
@@ -73,9 +80,9 @@ class TestAgentConfigConsistency:
|
|||||||
assert cfg["args"] == "{{args}}"
|
assert cfg["args"] == "{{args}}"
|
||||||
assert cfg["extension"] == ".toml"
|
assert cfg["extension"] == ".toml"
|
||||||
|
|
||||||
def test_agent_config_includes_tabnine(self):
|
def test_ai_help_includes_tabnine(self):
|
||||||
"""AGENT_CONFIG should include tabnine."""
|
"""CLI help text for --ai should include tabnine."""
|
||||||
assert "tabnine" in AGENT_CONFIG
|
assert "tabnine" in AI_ASSISTANT_HELP
|
||||||
|
|
||||||
# --- Kimi Code CLI consistency checks ---
|
# --- Kimi Code CLI consistency checks ---
|
||||||
|
|
||||||
@@ -95,9 +102,9 @@ class TestAgentConfigConsistency:
|
|||||||
assert kimi_cfg["dir"] == ".kimi/skills"
|
assert kimi_cfg["dir"] == ".kimi/skills"
|
||||||
assert kimi_cfg["extension"] == "/SKILL.md"
|
assert kimi_cfg["extension"] == "/SKILL.md"
|
||||||
|
|
||||||
def test_agent_config_includes_kimi(self):
|
def test_ai_help_includes_kimi(self):
|
||||||
"""AGENT_CONFIG should include kimi."""
|
"""CLI help text for --ai should include kimi."""
|
||||||
assert "kimi" in AGENT_CONFIG
|
assert "kimi" in AI_ASSISTANT_HELP
|
||||||
|
|
||||||
# --- Trae IDE consistency checks ---
|
# --- Trae IDE consistency checks ---
|
||||||
|
|
||||||
@@ -119,9 +126,9 @@ class TestAgentConfigConsistency:
|
|||||||
assert trae_cfg["args"] == "$ARGUMENTS"
|
assert trae_cfg["args"] == "$ARGUMENTS"
|
||||||
assert trae_cfg["extension"] == "/SKILL.md"
|
assert trae_cfg["extension"] == "/SKILL.md"
|
||||||
|
|
||||||
def test_agent_config_includes_trae(self):
|
def test_ai_help_includes_trae(self):
|
||||||
"""AGENT_CONFIG should include trae."""
|
"""CLI help text for --ai should include trae."""
|
||||||
assert "trae" in AGENT_CONFIG
|
assert "trae" in AI_ASSISTANT_HELP
|
||||||
|
|
||||||
# --- Pi Coding Agent consistency checks ---
|
# --- Pi Coding Agent consistency checks ---
|
||||||
|
|
||||||
@@ -144,9 +151,9 @@ class TestAgentConfigConsistency:
|
|||||||
assert pi_cfg["args"] == "$ARGUMENTS"
|
assert pi_cfg["args"] == "$ARGUMENTS"
|
||||||
assert pi_cfg["extension"] == ".md"
|
assert pi_cfg["extension"] == ".md"
|
||||||
|
|
||||||
def test_agent_config_includes_pi(self):
|
def test_ai_help_includes_pi(self):
|
||||||
"""AGENT_CONFIG should include pi."""
|
"""CLI help text for --ai should include pi."""
|
||||||
assert "pi" in AGENT_CONFIG
|
assert "pi" in AI_ASSISTANT_HELP
|
||||||
|
|
||||||
# --- iFlow CLI consistency checks ---
|
# --- iFlow CLI consistency checks ---
|
||||||
|
|
||||||
@@ -166,9 +173,9 @@ class TestAgentConfigConsistency:
|
|||||||
assert cfg["iflow"]["format"] == "markdown"
|
assert cfg["iflow"]["format"] == "markdown"
|
||||||
assert cfg["iflow"]["args"] == "$ARGUMENTS"
|
assert cfg["iflow"]["args"] == "$ARGUMENTS"
|
||||||
|
|
||||||
def test_agent_config_includes_iflow(self):
|
def test_ai_help_includes_iflow(self):
|
||||||
"""AGENT_CONFIG should include iflow."""
|
"""CLI help text for --ai should include iflow."""
|
||||||
assert "iflow" in AGENT_CONFIG
|
assert "iflow" in AI_ASSISTANT_HELP
|
||||||
|
|
||||||
# --- Goose consistency checks ---
|
# --- Goose consistency checks ---
|
||||||
|
|
||||||
@@ -188,9 +195,9 @@ class TestAgentConfigConsistency:
|
|||||||
assert cfg["goose"]["format"] == "yaml"
|
assert cfg["goose"]["format"] == "yaml"
|
||||||
assert cfg["goose"]["args"] == "{{args}}"
|
assert cfg["goose"]["args"] == "{{args}}"
|
||||||
|
|
||||||
def test_agent_config_includes_goose(self):
|
def test_ai_help_includes_goose(self):
|
||||||
"""AGENT_CONFIG should include goose."""
|
"""CLI help text for --ai should include goose."""
|
||||||
assert "goose" in AGENT_CONFIG
|
assert "goose" in AI_ASSISTANT_HELP
|
||||||
|
|
||||||
# --- invoke_separator propagation checks ---
|
# --- invoke_separator propagation checks ---
|
||||||
|
|
||||||
@@ -297,6 +304,6 @@ class TestAgentConfigConsistency:
|
|||||||
assert rovodev_cfg["args"] == "$ARGUMENTS"
|
assert rovodev_cfg["args"] == "$ARGUMENTS"
|
||||||
assert rovodev_cfg["extension"] == "/SKILL.md"
|
assert rovodev_cfg["extension"] == "/SKILL.md"
|
||||||
|
|
||||||
def test_agent_config_includes_rovodev(self):
|
def test_ai_help_includes_rovodev(self):
|
||||||
"""AGENT_CONFIG should include rovodev."""
|
"""CLI help text for --ai should include rovodev."""
|
||||||
assert "rovodev" in AGENT_CONFIG
|
assert "rovodev" in AI_ASSISTANT_HELP
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class TestSaveBranchNumbering:
|
|||||||
|
|
||||||
project_dir = tmp_path / "proj"
|
project_dir = tmp_path / "proj"
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", str(project_dir), "--integration", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
result = runner.invoke(app, ["init", str(project_dir), "--ai", "claude", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
|
saved = json.loads((project_dir / ".specify/init-options.json").read_text())
|
||||||
@@ -51,7 +51,7 @@ class TestBranchNumberingValidation:
|
|||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"])
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "foobar", "--ignore-agent-tools"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "Invalid --branch-numbering" in result.output
|
assert "Invalid --branch-numbering" in result.output
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ class TestBranchNumberingValidation:
|
|||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "sequential", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||||
|
|
||||||
@@ -69,6 +69,6 @@ class TestBranchNumberingValidation:
|
|||||||
from specify_cli import app
|
from specify_cli import app
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--integration", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
result = runner.invoke(app, ["init", str(tmp_path / "proj"), "--ai", "claude", "--branch-numbering", "timestamp", "--ignore-agent-tools", "--no-git", "--script", "sh"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Invalid --branch-numbering" not in (result.output or "")
|
assert "Invalid --branch-numbering" not in (result.output or "")
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ def test_commands_init_importable():
|
|||||||
def test_agent_config_importable():
|
def test_agent_config_importable():
|
||||||
from specify_cli._agent_config import (
|
from specify_cli._agent_config import (
|
||||||
AGENT_CONFIG,
|
AGENT_CONFIG,
|
||||||
|
AI_ASSISTANT_ALIASES,
|
||||||
|
AI_ASSISTANT_HELP,
|
||||||
DEFAULT_INIT_INTEGRATION,
|
DEFAULT_INIT_INTEGRATION,
|
||||||
SCRIPT_TYPE_CHOICES,
|
SCRIPT_TYPE_CHOICES,
|
||||||
)
|
)
|
||||||
assert isinstance(AGENT_CONFIG, dict)
|
assert isinstance(AGENT_CONFIG, dict)
|
||||||
|
assert isinstance(AI_ASSISTANT_ALIASES, dict)
|
||||||
|
assert isinstance(AI_ASSISTANT_HELP, str)
|
||||||
assert DEFAULT_INIT_INTEGRATION == "copilot"
|
assert DEFAULT_INIT_INTEGRATION == "copilot"
|
||||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Unit tests for extension skill auto-registration.
|
Unit tests for extension skill auto-registration.
|
||||||
|
|
||||||
Tests cover:
|
Tests cover:
|
||||||
- SKILL.md generation when skills mode was used during init
|
- SKILL.md generation when --ai-skills was used during init
|
||||||
- No skills created when ai_skills not active
|
- No skills created when ai_skills not active
|
||||||
- SKILL.md content correctness
|
- SKILL.md content correctness
|
||||||
- Existing user-modified skills not overwritten
|
- Existing user-modified skills not overwritten
|
||||||
@@ -162,7 +162,7 @@ def extension_dir(temp_dir):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def skills_project(project_dir):
|
def skills_project(project_dir):
|
||||||
"""Create a project with skills mode enabled and skills directory."""
|
"""Create a project with --ai-skills enabled and skills directory."""
|
||||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||||
return project_dir, skills_dir
|
return project_dir, skills_dir
|
||||||
@@ -170,7 +170,7 @@ def skills_project(project_dir):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def no_skills_project(project_dir):
|
def no_skills_project(project_dir):
|
||||||
"""Create a project without skills mode."""
|
"""Create a project without --ai-skills."""
|
||||||
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
||||||
return project_dir
|
return project_dir
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from tests.conftest import strip_ansi
|
|||||||
from specify_cli.extensions import (
|
from specify_cli.extensions import (
|
||||||
CatalogEntry,
|
CatalogEntry,
|
||||||
CORE_COMMAND_NAMES,
|
CORE_COMMAND_NAMES,
|
||||||
DEFAULT_HOOK_PRIORITY,
|
|
||||||
ExtensionManifest,
|
ExtensionManifest,
|
||||||
ExtensionRegistry,
|
ExtensionRegistry,
|
||||||
ExtensionManager,
|
ExtensionManager,
|
||||||
@@ -191,12 +190,6 @@ class TestNormalizePriority:
|
|||||||
assert normalize_priority(None, default=20) == 20
|
assert normalize_priority(None, default=20) == 20
|
||||||
assert normalize_priority("invalid", default=1) == 1
|
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 =====
|
# ===== ExtensionManifest Tests =====
|
||||||
|
|
||||||
@@ -465,137 +458,6 @@ class TestExtensionManifest:
|
|||||||
with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"):
|
with pytest.raises(ValidationError, match="Invalid hook 'after_tasks'"):
|
||||||
ExtensionManifest(manifest_path)
|
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):
|
def test_manifest_hash(self, extension_dir):
|
||||||
"""Test manifest hash calculation."""
|
"""Test manifest hash calculation."""
|
||||||
manifest_path = extension_dir / "extension.yml"
|
manifest_path = extension_dir / "extension.yml"
|
||||||
@@ -5044,405 +4906,6 @@ class TestExtensionPriorityBackwardsCompatibility:
|
|||||||
assert result[2][0] == "ext-low-priority"
|
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:
|
class TestHookInvocationRendering:
|
||||||
"""Test hook invocation formatting for different agent modes."""
|
"""Test hook invocation formatting for different agent modes."""
|
||||||
|
|
||||||
@@ -5469,7 +4932,7 @@ class TestHookInvocationRendering:
|
|||||||
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message
|
assert "EXECUTE_COMMAND_INVOCATION: /skill:speckit-plan" in message
|
||||||
|
|
||||||
def test_codex_hooks_render_dollar_skill_invocation(self, project_dir):
|
def test_codex_hooks_render_dollar_skill_invocation(self, project_dir):
|
||||||
"""Codex projects with skills mode should render $speckit-* invocations."""
|
"""Codex projects with --ai-skills should render $speckit-* invocations."""
|
||||||
init_options = project_dir / ".specify" / "init-options.json"
|
init_options = project_dir / ".specify" / "init-options.json"
|
||||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||||
init_options.write_text(json.dumps({"ai": "codex", "ai_skills": True}))
|
init_options.write_text(json.dumps({"ai": "codex", "ai_skills": True}))
|
||||||
|
|||||||
@@ -2557,8 +2557,8 @@ class TestPresetSkills:
|
|||||||
return preset_dir
|
return preset_dir
|
||||||
|
|
||||||
def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):
|
def test_skill_overridden_on_preset_install(self, project_dir, temp_dir):
|
||||||
"""When skills mode was used, a preset command override should update the skill."""
|
"""When --ai-skills was used, a preset command override should update the skill."""
|
||||||
# Simulate skills mode having been used: write init-options + create skill
|
# Simulate --ai-skills having been used: write init-options + create skill
|
||||||
self._write_init_options(project_dir, ai="claude")
|
self._write_init_options(project_dir, ai="claude")
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
skills_dir = project_dir / ".claude" / "skills"
|
||||||
self._create_skill(skills_dir, "speckit-specify")
|
self._create_skill(skills_dir, "speckit-specify")
|
||||||
@@ -2843,7 +2843,7 @@ class TestPresetSkills:
|
|||||||
assert "override taskstoissues body" in content
|
assert "override taskstoissues body" in content
|
||||||
|
|
||||||
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
|
def test_skill_not_updated_when_ai_skills_disabled(self, project_dir, temp_dir):
|
||||||
"""When skills mode was NOT used, preset install should not touch skills."""
|
"""When --ai-skills was NOT used, preset install should not touch skills."""
|
||||||
self._write_init_options(project_dir, ai="qwen", ai_skills=False)
|
self._write_init_options(project_dir, ai="qwen", ai_skills=False)
|
||||||
skills_dir = project_dir / ".qwen" / "skills"
|
skills_dir = project_dir / ".qwen" / "skills"
|
||||||
self._create_skill(skills_dir, "speckit-specify", body="untouched")
|
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):
|
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."""
|
"""Skills should not be created when no existing skill dir is found."""
|
||||||
self._write_init_options(project_dir, ai="claude")
|
self._write_init_options(project_dir, ai="claude")
|
||||||
# Don't create skills dir — simulate skills mode never created them
|
# Don't create skills dir — simulate --ai-skills never created them
|
||||||
|
|
||||||
manager = PresetManager(project_dir)
|
manager = PresetManager(project_dir)
|
||||||
install_self_test_preset(manager)
|
install_self_test_preset(manager)
|
||||||
@@ -4123,7 +4123,7 @@ class TestWrapStrategy:
|
|||||||
"---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n"
|
"---\ndescription: core wrap-test\n---\n\n# Core Wrap-Test Body\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up skills dir (simulating --integration claude)
|
# Set up skills dir (simulating --ai claude)
|
||||||
skills_dir = project_dir / ".claude" / "skills"
|
skills_dir = project_dir / ".claude" / "skills"
|
||||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||||
skill_subdir = skills_dir / "speckit-wrap-test"
|
skill_subdir = skills_dir / "speckit-wrap-test"
|
||||||
|
|||||||
Reference in New Issue
Block a user